From 040078288da1cd83fa7d3ac41d5cec7e2b4db49e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 19:56:45 -0600 Subject: [PATCH 1/3] [#32] - history panel enhancements Assisted-by: Claude Code --- src/DebugBar/History/HistoryCookie.php | 22 ++++- src/DebugBar/History/HistoryOptions.php | 6 ++ tests/JavaScript/debugbar.test.js | 96 +++++++++---------- .../DebugBar/History/HistoryCookieTest.php | 22 ++++- .../DebugBar/History/HistoryOptionsTest.php | 8 ++ 5 files changed, 100 insertions(+), 54 deletions(-) diff --git a/src/DebugBar/History/HistoryCookie.php b/src/DebugBar/History/HistoryCookie.php index c88db78..5988205 100644 --- a/src/DebugBar/History/HistoryCookie.php +++ b/src/DebugBar/History/HistoryCookie.php @@ -16,6 +16,7 @@ use Closure; use function bin2hex; +use function headers_sent; use function is_string; use function preg_match; use function random_bytes; @@ -31,13 +32,21 @@ final class HistoryCookie private const ID_PATTERN = '/^[a-f0-9]{64}$/D'; + private readonly Closure $headersSent; + private readonly Closure $setCookie; - public function __construct(private readonly ?string $value, ?callable $setCookie = null) - { - $this->setCookie = null === $setCookie + public function __construct( + private readonly ?string $value, + ?callable $setCookie = null, + ?callable $headersSent = null + ) { + $this->setCookie = null === $setCookie ? setcookie(...) : Closure::fromCallable($setCookie); + $this->headersSent = null === $headersSent + ? headers_sent(...) + : Closure::fromCallable($headersSent); } public static function fromGlobals(): self @@ -52,9 +61,14 @@ public function id(): ?string return 1 === preg_match(self::ID_PATTERN, $this->value ?? '') ? $this->value : null; } + /** + * Sets a new identity when the browser has no valid one. When output started + * before the response, the headers are sent and the call does nothing, so + * `setcookie()` gives no warning. + */ public function queue(string $path, bool $secure): void { - if (null !== $this->id()) { + if (null !== $this->id() || true === ($this->headersSent)()) { return; } diff --git a/src/DebugBar/History/HistoryOptions.php b/src/DebugBar/History/HistoryOptions.php index 3f10188..7f750bc 100644 --- a/src/DebugBar/History/HistoryOptions.php +++ b/src/DebugBar/History/HistoryOptions.php @@ -95,6 +95,12 @@ public function validate(): void 'history.url must be an absolute path without a scheme, host, query, or fragment.' ); } + + if ('/' === $this->url) { + throw new InvalidArgumentException( + 'history.url must not be "/". The endpoint would replace the home page of the application.' + ); + } } private function isAbsolutePath(string $path): bool diff --git a/tests/JavaScript/debugbar.test.js b/tests/JavaScript/debugbar.test.js index 0633aa9..8e1da93 100644 --- a/tests/JavaScript/debugbar.test.js +++ b/tests/JavaScript/debugbar.test.js @@ -97,6 +97,18 @@ function deferred() { return {promise: promise, reject: reject, resolve: resolve}; } +function historyOptions(overrides) { + return Object.assign({ + panel: {url: '/_debugbar/open'}, + selectedId: '', + onSelect: function () {}, + onClear: function () {}, + isCurrent: function () { + return true; + } + }, overrides); +} + test('browser execution does not replace a host CommonJS export', function () { var hostExports = {host: true}; var context = { @@ -188,9 +200,7 @@ test('empty request history renders its empty state', async function () { }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions()); await dom.flushPromises(); assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-empty').textContent, 'No stored requests'); @@ -215,16 +225,7 @@ test('request history renders request metadata and selection', async function () }; var mount = new dom.TestElement('div'); - renderHistoryBrowser( - mount, - {url: '/_debugbar/open'}, - 'request-one', - function () {}, - function () {}, - function () { - return true; - } - ); + renderHistoryBrowser(mount, historyOptions({selectedId: 'request-one'})); await dom.flushPromises(); assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-method').textContent, 'POST'); @@ -258,11 +259,11 @@ test('selecting a request loads and exposes its stored payload', async function }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function (payload, metadata, id) { - selected = {payload: payload, id: id}; - }, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + onSelect: function (payload, metadata, id) { + selected = {payload: payload, id: id}; + } + })); await dom.flushPromises(); var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); @@ -296,11 +297,11 @@ test('selecting a request rejects an incompatible stored payload version', async }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () { - selected = true; - }, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + onSelect: function () { + selected = true; + } + })); await dom.flushPromises(); var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); @@ -326,9 +327,7 @@ test('refresh replaces the rendered request list', async function () { }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions()); await dom.flushPromises(); dom.findByClass(mount, 'phalcon-debugbar-history-action').click(); @@ -363,11 +362,11 @@ test('clear blocks interaction and refreshes after deletion', async function () }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () { - cleared++; - }, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + onClear: function () { + cleared++; + } + })); await dom.flushPromises(); var refresh = dom.findByClass(mount, 'phalcon-debugbar-history-action'); @@ -399,9 +398,7 @@ test('request history reports list failures and restores refresh', async functio }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions()); await dom.flushPromises(); assert.equal( @@ -429,11 +426,11 @@ test('request history restores a row after a detail failure', async function () }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () { - selected = true; - }, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + onSelect: function () { + selected = true; + } + })); await dom.flushPromises(); var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); @@ -466,11 +463,11 @@ test('request history reports clear failures and restores controls', async funct }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () { - cleared = true; - }, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + onClear: function () { + cleared = true; + } + })); await dom.flushPromises(); var refresh = dom.findByClass(mount, 'phalcon-debugbar-history-action'); @@ -683,11 +680,12 @@ test('the client accepts the stored entry of the real history code', async funct }; var mount = new dom.TestElement('div'); - renderHistoryBrowser(mount, contract.payload.data.history.panel, '', function (payload, metadata, id) { - selected = {payload: payload, metadata: metadata, id: id}; - }, function () {}, function () { - return true; - }); + renderHistoryBrowser(mount, historyOptions({ + panel: contract.payload.data.history.panel, + onSelect: function (payload, metadata, id) { + selected = {payload: payload, metadata: metadata, id: id}; + } + })); await dom.flushPromises(); var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); diff --git a/tests/Unit/DebugBar/History/HistoryCookieTest.php b/tests/Unit/DebugBar/History/HistoryCookieTest.php index 50a8e58..f669d17 100644 --- a/tests/Unit/DebugBar/History/HistoryCookieTest.php +++ b/tests/Unit/DebugBar/History/HistoryCookieTest.php @@ -29,7 +29,8 @@ function (string $name, string $value, array $options) use (&$call): bool { $call = [$name, $value, $options]; return true; - } + }, + fn (): bool => false ); $cookie->queue('/app/', true); @@ -48,6 +49,25 @@ function (string $name, string $value, array $options) use (&$call): bool { ); } + public function testSentHeadersSkipTheCookieWriter(): void + { + $called = false; + $cookie = new HistoryCookie( + null, + function () use (&$called): bool { + $called = true; + + return true; + }, + fn (): bool => true + ); + + $cookie->queue('/', false); + + $this->assertFalse($called); + $this->assertNull($cookie->id()); + } + public function testValidCookieIsReusedWithoutCallingTheCookieWriter(): void { $id = str_repeat('a', 64); diff --git a/tests/Unit/DebugBar/History/HistoryOptionsTest.php b/tests/Unit/DebugBar/History/HistoryOptionsTest.php index 7b2ebb0..81f3db4 100644 --- a/tests/Unit/DebugBar/History/HistoryOptionsTest.php +++ b/tests/Unit/DebugBar/History/HistoryOptionsTest.php @@ -62,6 +62,14 @@ public function testEnabledHistoryAcceptsAbsoluteStoragePaths(): void $this->assertSame('/', $root->path); } + public function testEnabledHistoryRejectsTheRootUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('history.url must not be "/".'); + + (new HistoryOptions(true, '/', '/var/debugbar'))->validate(); + } + public function testEnabledHistoryRequiresAnAbsoluteStoragePath(): void { $this->expectException(InvalidArgumentException::class); From ed1670b938697ead30de5c3c5a3af8f25c38d5fb Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 19:57:08 -0600 Subject: [PATCH 2/3] [#32] - refactoring options Assisted-by: Claude Code --- resources/assets/debugbar.js | 61 +++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index 1e6aa31..3693de2 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -116,10 +116,7 @@ return value; } - function renderIndicators(mount, data, widgets, onHistoryToggle, historyOpen, requestMetadata, bindPanel) { - mount.innerHTML = ''; - var historyTrigger = null; - + function renderMetricIndicators(mount, data, widgets, bindPanel) { Object.keys(widgets).forEach(function (collector) { var definition = (widgets[collector] || {}).indicator; if (!definition || !data[collector]) { @@ -134,15 +131,21 @@ bindPanel(collector, indicator); mount.appendChild(indicator); }); + } + function renderRequestControl(mount, options) { + var widgets = options.widgets; + var onHistoryToggle = options.onHistoryToggle; + var historyOpen = options.historyOpen; + var historyTrigger = null; var requestName = Object.keys(widgets).find(function (name) { return widgets[name] && widgets[name].request; }); var requestDefinition = requestName ? widgets[requestName].request : {}; - var request = requestName ? (data[requestName] || {}) : {}; + var request = requestName ? (options.data[requestName] || {}) : {}; var method = scalar(valueAtPath(request, requestDefinition.method || [])); var uri = scalar(valueAtPath(request, requestDefinition.uri || [])); - requestMetadata = requestMetadata || {}; + var requestMetadata = options.requestMetadata || {}; method = method || scalar(requestMetadata.method); uri = uri || scalar(requestMetadata.uri); if (method || uri || onHistoryToggle) { @@ -423,9 +426,10 @@ }; } - function renderHistoryBrowser(mount, panel, selectedId, onSelect, onClear, isCurrent) { + function renderHistoryBrowser(mount, options) { mount.innerHTML = ''; + var panel = options.panel; var url = panel && typeof panel.url === 'string' ? panel.url : ''; if (!url || typeof window.fetch !== 'function') { mount.style.display = 'none'; @@ -439,7 +443,7 @@ var refreshButton = el('button', 'phalcon-debugbar-history-action', 'Refresh'); var clearButton = el('button', 'phalcon-debugbar-history-action is-danger', 'Clear'); var content = el('div', 'phalcon-debugbar-history-content'); - var requestGuard = createHistoryRequestGuard(isCurrent); + var requestGuard = createHistoryRequestGuard(options.isCurrent); refreshButton.type = 'button'; clearButton.type = 'button'; clearButton.disabled = true; @@ -483,7 +487,7 @@ var id = scalar(request.id); var button = el('button', 'phalcon-debugbar-history-request'); button.type = 'button'; - if (id === selectedId) { + if (id === options.selectedId) { button.classList.add('is-selected'); } @@ -522,7 +526,7 @@ } ); button.classList.add('is-selected'); - onSelect(detail.request.payload, detail.request.meta || {}, id); + options.onSelect(detail.request.payload, detail.request.meta || {}, id); } button.disabled = false; }).catch(function () { @@ -565,7 +569,7 @@ return; } - onClear(); + options.onClear(); refresh(); }).catch(function () { if (requestGuard.finishClear(generation)) { @@ -657,24 +661,23 @@ } var generation = ++historyRenderGeneration; - renderHistoryBrowser( - historyBrowser, - historyPanel, - selectedHistoryId, - function (storedPayload, storedMetadata, id) { + renderHistoryBrowser(historyBrowser, { + panel: historyPanel, + selectedId: selectedHistoryId, + onSelect: function (storedPayload, storedMetadata, id) { var preferredActive = activeBeforeHistory; selectedHistoryId = id; setHistoryOpen(false); activeBeforeHistory = null; renderData(storedPayload, preferredActive, storedMetadata); }, - function () { + onClear: function () { selectedHistoryId = ''; }, - function () { + isCurrent: function () { return historyOpen && generation === historyRenderGeneration; } - ); + }); } function setHistoryOpen(open) { @@ -786,21 +789,21 @@ tabs.appendChild(tab); }); - historyTrigger = renderIndicators( - indicators, - data, - widgets, - historyPanel ? function () { + indicators.innerHTML = ''; + renderMetricIndicators(indicators, data, widgets, bindPanel); + historyTrigger = renderRequestControl(indicators, { + data: data, + widgets: widgets, + requestMetadata: requestMetadata, + historyOpen: historyOpen, + onHistoryToggle: historyPanel ? function () { if (!historyOpen) { activeBeforeHistory = active; closePanel(); } setHistoryOpen(!historyOpen); - } : null, - historyOpen, - requestMetadata, - bindPanel - ); + } : null + }); if (preferred) { activate(preferred[0], preferred[1], preferred[2], preferred[3]); From df315f435af75487fefae2254ab6965d34649f37 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 19:57:24 -0600 Subject: [PATCH 3/3] [#32] - correcting/updating changelog and docs Assisted-by: Claude Code --- CHANGELOG.md | 13 ++++++++----- docs/index.md | 16 +++++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a7bb1..2fe4de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas ### Added -- `memory` collector for current and peak PHP memory use. Time and Memory now open from indicators on the right, not from tabs. +- `memory` collector for current and peak PHP memory use. Memory opens from an indicator on the right. - 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) @@ -29,6 +29,11 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `Security\Redactor::redactUri()` applies the redaction rule to the query of a URI. [#62](https://github.com/phalcon/debugbar/issues/62) - A shared fixture, `tests/support/assets/DebugBar/panel-contract.json`, makes the PHP and JavaScript tests use the same payload. [#62](https://github.com/phalcon/debugbar/issues/62) - `redact.unmask` takes patterns for masked keys whose values show, and `Security\Redactor` takes them as an optional third argument. A pattern matches the whole key, and `*` matches zero or more characters. A hidden key stays hidden. [#67](https://github.com/phalcon/debugbar/issues/67) +- The documentation states the effect of a long-lived worker on request history. [#62](https://github.com/phalcon/debugbar/issues/62) +- The documentation states that `access.callback` can run more than once for a request. +- Request history does not set its cookie when output started before the response, so `setcookie()` gives no warning. [#32](https://github.com/phalcon/debugbar/issues/32) +- `history.url` cannot be `/`. The documentation states that the history endpoint wins over an application route with the same path, for each HTTP method. [#32](https://github.com/phalcon/debugbar/issues/32) +- The documentation states that a browser in request history is one history cookie, so all its windows and tabs count as one for `history.max_browsers`. [#32](https://github.com/phalcon/debugbar/issues/32) ### Changed @@ -43,14 +48,12 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - The debug page in ADR applications uses `Injector::isHtml()` and no longer depends on the injection rules of the bar. [#62](https://github.com/phalcon/debugbar/issues/62) - `DebugBarTypes` lists the icon names as `icon_type`, so static analysis rejects an unknown `indicator.icon`. A test keeps the list equal to the drawings in `debugbar.js`. An indicator with an unknown icon name now shows no icon, not the gears. [#64](https://github.com/phalcon/debugbar/issues/64) - `redact.mask` and `redact.hidden` also match the word parts of a key. The redactor splits a key at `_`, `-`, `.` and camelCase, so `token` now masks `reset_token` and `accessToken`. Some values that showed before now show `***`. [#67](https://github.com/phalcon/debugbar/issues/67) +- The Time panel opens from an indicator on the right, not from a tab. +- The bar sends `meta.widgets`, so each tab shows the label of its collector and each panel uses its declared type. The `logger` tab now reads "Logs", not "Logger". ### Fixed -- 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. - The `request` panel and request history no longer show or store a query value under a masked key in the URI. [#62](https://github.com/phalcon/debugbar/issues/62) -- The documentation states the effect of a long-lived worker on request history. [#62](https://github.com/phalcon/debugbar/issues/62) -- The documentation states that `access.callback` can run more than once for a request. [#66](https://github.com/phalcon/debugbar/issues/66) ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 9406310..ae2bc2e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -192,7 +192,7 @@ $container->set( The adapter reads the active bar each time the logger writes, so you can create the logger before the provider boots the bar. An exception that the logger writes before the boot is lost. The adapter does not change the response and does not keep the exception from the error responder. The panel also shows `RouteNotFound` for a URL with no route, because the error responder logs this too. If the error responder itself fails, the application returns a bare 500 response and logs nothing, so the panel does not show the exception. -The `history` keys turn on request history. You do not have to add a route. Set `history.base_uri` when the application is not at the root of the host, because an ADR application has no `url` service by default. +The `history` keys turn on request history. You do not have to add a route. Set `history.base_uri` when the application is not at the root of the host, because an ADR application has no `url` service by default. The history URL wins over an application route with the same path, for each HTTP method. ```php ` | `[]` | Keys dropped from the output entirely. | | `redact.mask` | `list` | `[]` | Extra keys whose values are masked (added to the defaults). | @@ -343,7 +343,10 @@ providers and module services have registered their final definitions. If its base URI is `/app1/` or `https://example.test/app1/`, the endpoint path is `/app1/_debugbar/open`. The internal controller creates its own response instead of resolving the application's shared `response` during boot. `history.url` must -start with `/` and cannot contain a scheme, host, query, or fragment. +start with `/` and cannot contain a scheme, host, query, or fragment. It cannot be +`/`, because the endpoint would replace the home page of the application. The +endpoint wins over an application route with the same path, for each HTTP method, +so use a path that the application does not use. The endpoint uses the application's normal MVC lifecycle, so module initialization and application event listeners still run. A GET without an `id` returns the recent request metadata; `?id=` returns @@ -391,13 +394,16 @@ cookie mechanism so it does not replace cookies queued by the application. The b never reads, starts, or changes the application's PHP session, so session ID regeneration does not hide earlier requests. The first allowed response for a browser sets the cookie but is not stored; storage begins with the next request -carrying that cookie. Closing the browser discards the identity while its files +carrying that cookie. If output starts before the response, the bar cannot set the +history cookie, and request history does not start for that browser. Closing the browser discards the identity while its files remain eligible for cleanup until `history.ttl_seconds` expires. Clients that do not retain cookies never create storage directories. The number of browser directories is capped by `history.max_browsers`; creation is serialized and evicts the least recently updated browser directory before admitting another identity. This bounds forged or repeatedly rotated cookie identities even when the access -allowlist is empty. Each stored +allowlist is empty. A browser here is one history cookie. All the windows and +tabs of a browser share the cookie, so they count as one browser. A private +window gets its own cookie, so it counts as another browser. Each stored entry distinguishes the request start time (`requested_at`) from the time it was persisted (`stored_at`). If the server does not expose `REQUEST_TIME_FLOAT`, the persistence time is used for both values. Each payload has a small metadata sidecar,