Skip to content

13 August 2026

4 changes:

  • The toolbar finds the log endpoint itself
  • The stream driver had the gap it was built to close
  • The request body is back in the toolbar
  • The Time tab starts answering the question in its name

The toolbar finds the log endpoint itself

GET /devpanel/logs?request=<id> shipped yesterday with its URL advertised in the data island DebugBar::render() emits — which a SPA never sees, since its shell is a static file no middleware touches. The obvious fix was to put the URL in ApiDebugPayload::build() so every response carried it. That was the wrong fix, and it did not survive review.

The route is a framework constant: the same path in every installation. Putting it in every debug payload means sending the client something it could already work out, on every annotated response, for the lifetime of the page. A response should carry what only it knows.

Changed

Nothing about the endpoint travels in a payload. The toolbar knows the path and resolves the base from what the page already knows about itself:

  • window.__PRAMNOS__.base in a SPA — the one case that cannot be inferred, because the API need not live where the page does, and the SPA shell already publishes this for its own router;
  • the document's own base URL otherwise, which is right for a server-rendered page including one served from a subdirectory.

Whether the route answers is settled by the answer: 404 (feature off), 403 or 401 (grant expired) retire the offer for the rest of the page, with the reason on the button. Feature detection by use rather than by advertisement — and it covers the case an advertisement never could, a grant that expires while the page is open.

DebugBar::logsUrl() is gone; it had no callers left.

Added

DevPanelController::logs() now has the access-control tests it should have shipped with — it is the one route in the framework that hands over log lines: a grant without an admin user is allowed, an ordinary user without a grant is refused, an admin without a grant is allowed, a malformed or missing id is refused before anything is read, and with the DevPanel feature off the route is a 404 whatever grant the caller holds.

The stream driver had the gap it was built to close

RedisStreamDriver shipped yesterday to fix SSE losing events across a reconnect. Its first integration test — against a live Redis rather than the fake the unit tests drive — showed it had the same bug, in miniature, on every single read.

The consume loop started each channel at Redis's $ cursor, which reads as "only what arrives from now on". That is what it means, but only for one read: $ resolves to whatever is newest at the moment the read is issued. So a loop that blocks, times out, and re-issues the read with $ again silently skips everything published in between.

$redis->xAdd('t:x', '*', ['a' => '1']);
$redis->xRead(['t:x' => '$'], 0, 300);   // blocks, times out, returns nothing
$redis->xAdd('t:x', '*', ['a' => '2']);  // published in the gap
$redis->xRead(['t:x' => '$'], 0, 300);   // → nothing. The entry is invisible.

Once per read timeout, for the lifetime of every subscription. The driver built to close a gap had reopened it, smaller and more often.

Fixed

"Start from now" is resolved to a concrete entry id — the stream's newest, via XREVRANGE, or 0-0 when the stream is empty, which is right precisely because an empty stream has no history to replay. Every subsequent read continues from where the last one stopped, so there is no moment the cursor is not pointing at something.

Why the unit tests could not have caught it

They drive an injected fake, and the fake returned whatever the test scripted regardless of the cursor — it could not model the one behaviour that mattered, because that behaviour lives in Redis. The unit tests proved the cursor arithmetic; only a server could answer what $ actually means across two reads.

tests/Integration/Broadcasting/RedisStreamDriverIntegrationTest.php now covers it against the live container: the envelope round trip, replay from a cursor across a gap, no replay without one, trimming under MAXLEN ~, and an event published mid-subscription arriving live — which is the test that failed.

The Realtime guide gains a note for anyone implementing SubscribableDriverInterface over another log: a cursor has to be a fixed point, not a moving one.

The request body is back in the toolbar

The old ajax panel kept what the page had sent: captured from fetch and XMLHttpRequest, form-urlencoded bodies decoded into the structure they encode, secrets masked, the lot collapsed behind a <details>. Unifying the two renderers lost it — entry.body was still recorded and drawn nowhere.

"What did I send" is the first question when a call comes back wrong, and it was answerable only in the browser's own network panel, which does not know which request the toolbar is showing.

Added

The body of the selected request, above whichever tab is open — it belongs to the request rather than to any collector.

  • Captured from both transports, synchronously and without consuming anything: a string is already text, URLSearchParams and FormData are walked, and a Blob or an ArrayBuffer is described ([binary, 4096 bytes]) rather than decoded, because reading one is asynchronous and the object belongs to the application. Only fetch's init body — a Request instance owns its own stream, and reading it would consume what is about to be sent.
  • Form bodies decoded. columns%5B0%5D%5Bdata%5D=userid is nested data written flat and then escaped; a datatables request is fifty of those, and as raw text it is unreadable, which is the same as not being shown.
  • Collapsed, with its size, because two kilobytes of column metadata expanded would push everything worth reading off the screen.
  • Masked. The body never leaves the browser — nothing is added to the request and nothing is transmitted to produce it — but the panel gets screenshotted and shared, and a password in a bug report is a password that has to be changed.

Limits, so it cannot become the problem

  • 8KB per body, and the panel says when it cut one — a body that looks complete and is not is worse than one that admits where it stops. Fifty file uploads in history is how a debugging aid runs a tab out of memory.
  • No layout above 2KB. Pretty-printing means parsing, re-serialising and walking every key to mask it: nothing for two kilobytes, real work for eight, and it would run on the render path that must never be why a page feels slow. The browser's own network panel draws this line somewhere too. Masking is not part of the trade — it applies at every size.
  • Computed once. render() runs for every recorded request, so a polling page would otherwise re-lay-out the same body every few seconds. The result is kept on the entry.

The Time tab starts answering the question in its name

It showed one segment and a start time. On a SPA it showed one segment, because routing and controller are instrumented on the MVC path and the API path has no timers at all. What it never showed was the two things the toolbar already knew and had never subtracted.

Added

Client versus server, as one bar and one sentence.

client 210ms = server 42ms + 168ms elsewhere

Both numbers were already there — the browser measured the call, the server reported its share — and the difference is network, queueing and the browser's own work. A call that spends 40ms in PHP and 210ms in the air is not a slow endpoint, and optimising the endpoint is the wrong afternoon.

SQL as a share of server time, from the query collector's total_ms. "24ms of 40ms was the database" is the difference between an indexing problem and an application one. Red above half.

Both are absent rather than zero when a number is missing: a bar claiming 0ms of network for a response that only carried a header would be inventing.

A waterfall across requests, in the requests tab, oldest first on a shared axis — the way a browser's network panel reads. This is the insight no per-request tab can give: three calls of 200ms each are a 200ms page if they overlap and a 600ms page if they do not, and a tab that shows each of them separately cannot tell you which you have. A polling loop looks like a comb; a staircase is a chain of calls waiting on each other. Failed requests are red there too, and clicking a bar picks that request.

A request with no client duration — the page itself, or a response that only carried a header — is drawn as a mark rather than a bar, because a width would be a guess.

Still to come

The other half of this: instrumenting the API path the way MVC is (route, middleware, action, serialize) and a real bootstrap segment around Application::init(), where DB connect, service providers and session start are the classic invisible cost. The boot segment that exists today measures the DebugBar provider's own registration window, not application boot, and is misnamed for it.