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.

ts
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.

jsonc
[
  { "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.

ts
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") },
});

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.

Edit this page on GitHub