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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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)

Expand Down
16 changes: 11 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
Expand Down Expand Up @@ -308,7 +308,7 @@ The second argument to `Provider` is a nested array. Every key is optional. `Pha
| `history.url` | `string` | `/_debugbar/open` | Internal GET/DELETE endpoint path, relative to the application's base URI. |
| `history.path` | `string` | required when enabled | Absolute writable storage directory; keep it outside the document root. |
| `history.max_requests` | `int` | `100` | Maximum stored requests per browser. |
| `history.max_browsers` | `int` | `10` | Maximum browser directories; the least recently updated is evicted first. |
| `history.max_browsers` | `int` | `10` | Maximum browsers with stored requests. A browser is one history cookie, so its windows and tabs count as one. The least recently updated is evicted first. |
| `history.ttl_seconds` | `int` | `86400` | Lifetime in seconds; active-browser entries are checked immediately. |
| `redact.hidden` | `list<string>` | `[]` | Keys dropped from the output entirely. |
| `redact.mask` | `list<string>` | `[]` | Extra keys whose values are masked (added to the defaults). |
Expand Down Expand Up @@ -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=<request-id>` returns
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 32 additions & 29 deletions resources/assets/debugbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -565,7 +569,7 @@
return;
}

onClear();
options.onClear();
refresh();
}).catch(function () {
if (requestGuard.finishClear(generation)) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down
22 changes: 18 additions & 4 deletions src/DebugBar/History/HistoryCookie.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions src/DebugBar/History/HistoryOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading