Evidence
What the network did
Breadcrumbs say what the reporter did; bugbottle/network says what the
browser did about it. "The save button does nothing" is a different report
when it arrives with the 500 from POST /api/orders that caused it. It is a
separate entry point too, and opt-in: it patches fetch and
XMLHttpRequest, which is a bigger promise than adding a listener.
import { initNetwork } from "bugbottle/network";
initNetwork({ endpoint: "/api/feedback" });Only the interesting requests are kept: a status of 400 or more, a request
that failed before it got a status (status: 0, error: true), and anything
slower than slowMs — 2000 ms by default. A fast 200 is the request that
worked, and there are hundreds of those in a session; recorded, they would
evict the one that explains the report. The last 30 are kept.
[
{ "ts": "2026-09-07T08:12:31.004Z", "method": "POST", "url": "/api/orders", "status": 500, "ms": 812 },
{ "ts": "2026-09-07T08:12:33.900Z", "method": "GET", "url": "/api/orders/42", "status": 0, "ms": 30, "error": true },
{ "ts": "2026-09-07T08:12:36.100Z", "method": "GET", "url": "https://api.stripe.com/v1/charges", "status": 200, "ms": 3400 }
]buildReport attaches them on its own while the recorder is active, as
network; pass includeNetwork: false to leave them out of one report.
toMarkdown renders them as a "Requests" table.
Never recorded: request or response bodies, and never headers. That is
where tokens, cookies and personal data live, and a bug report is not the
place for any of them. What is left is the method, the URL, the status and the
duration. The URL keeps its path and query with sensitive query values
redacted (?token=… becomes ?token=[redacted]); a cross-origin URL keeps
its origin, because which host failed is half the answer.
initNetwork({
endpoint: "/api/feedback",
slowMs: 2000,
all: false,
maxEntries: 30,
ignore: (url) => url.startsWith("/api/analytics"),
beforeRequest: (entry) =>
entry.url.startsWith("/admin") ? null : { ...entry, url: entry.url.replace(/\/\d+/, "/:id") },
});all: truerecords every request, not only the failed and the slow ones.beforeRequestsees every entry before it is stored. Return null to drop it, or a changed one to redact it. A hook that throws drops the entry and never reaches your application.- Requests to
endpointare skipped, so a report never describes its own delivery.ignorereplaces that check when you need a different rule.
The patched fetch always calls the original and hands back its result
untouched, rejections included; XMLHttpRequest is timed with loadend, the
one event that fires for every ending. getNetwork() returns a copy of what
has been recorded, and resetNetwork() empties it and puts both globals back
as it found them. initNetwork returns that same function as its stop().
The ready-made panel can start it for you: network: initNetwork on
mountBugbottle records for as long as the panel is mounted and stops on
destroy(), and the panel's own endpoint is passed on so a report never
describes its own delivery. { on: initNetwork, all: true } tunes it. The
panel never imports the module — you hand it in — so a page that records
nothing carries nothing. From the script tag it is data-network, which is
the same switch.