How to capture console logs and network requests from a user's browser

JavaScript SDK

Every debugging session starts in the same place: you open DevTools. The console has the error, the network tab has the failing request, and within a minute you know what went wrong.

The problem is that DevTools is open on your machine, and the bug is happening on theirs.

This is a guide to closing that distance — instrumenting a page so that when something breaks in a user's browser, you get the same three panels you would have opened yourself. It covers what to patch, in what order, and the specific traps that turn a naive implementation into a source of new bugs.

Start with the ordering problem

Almost every guide to capturing console output starts with the monkey patch. That is the wrong place to start, because the hard part is not how you patch — it is when.

Your instrumentation only sees what happens after it loads. Everything logged before that point is gone. And the errors you most want are disproportionately early ones: a failed config fetch, a third-party script that threw during initialization, a hydration mismatch. By the time your bug-reporting bundle has downloaded, parsed, and initialized, the interesting part of the timeline is already history.

There are two ways to handle this, and you want both.

Load the capture layer first. Put it before your application bundle, synchronously, in <head>. Keep it small — this is the argument for a capture stub that installs the hooks and nothing else, with the heavier UI loading lazily afterwards.

Buffer before you have anywhere to put things. The hooks should write into an in-memory ring buffer from the first millisecond, long before anyone has clicked "report a bug." When a report is finally opened, the buffer is already full of history. This is what makes it possible to capture logs from before the widget was opened at all — the widget was never the thing doing the capturing.

Patching console without breaking it

The mechanics are simple:

js
const BUFFER = [];
const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];

for (const level of LEVELS) {
  const original = console[level].bind(console);
  console[level] = (...args) => {
    BUFFER.push({ level, ts: Date.now(), args: serialize(args) });
    original(...args);
  };
}

Four things go wrong here in practice.

You break DevTools' source attribution. The console now reports every line as originating from your wrapper instead of the call site. Chrome and Firefox both respect a //# sourceURL comment and blackboxing rules, but the reliable fix is to capture a stack trace at call time and store the caller's frame alongside the entry. You are going to want that frame in the report anyway.

You recurse into yourself. If serialize throws, and your error handler logs, and your logging is patched, you have an infinite loop that hard-locks the tab. Guard with a reentrancy flag and make the whole hook body a no-op if anything inside it fails. Instrumentation that crashes the page is worse than no instrumentation.

You keep objects alive. Pushing raw arguments into a buffer holds a strong reference to every object ever logged — DOM nodes, response bodies, entire component trees. On a long-lived single-page app this is a memory leak that grows for as long as the tab is open. Serialize eagerly to a bounded string representation and drop the reference.

You are not the only one patching. Analytics scripts, error trackers, and framework dev tools all patch console too. If you capture the original at load time and someone patches after you, their wrapper sits above yours and you still see everything. If they patched first, you see their wrapper's output. Both work; what does not work is assuming you are alone. Never restore console[level] to the original on teardown — you will clobber whoever wrapped it after you. Restore only if you are still the top of the chain.

Serialization is most of the work

JSON.stringify fails on circular references, silently drops functions and undefined, throws on BigInt, and turns Error objects into {} — which is exactly the case you care about most.

A serializer good enough for bug reports needs to handle: circular references (replace with a marker), Error instances (name, message, and stack, explicitly), DOM nodes (tag, id, classes — not the subtree), Map and Set, and depth and length limits so one enormous object cannot consume the entire buffer. Truncate long strings with an explicit marker rather than silently. A log line reading "[truncated at 2000 chars]" tells a developer something; a line that just stops does not.

Errors you never logged

Most production errors are never passed to console.error by your code. They are thrown, and something else handles them. Two global hooks cover the gap:

js
window.addEventListener('error', (e) => {
  record({ kind: 'uncaught', message: e.message, filename: e.filename,
           line: e.lineno, col: e.colno, stack: e.error?.stack });
}, true);

window.addEventListener('unhandledrejection', (e) => {
  record({ kind: 'unhandled-rejection', reason: serialize(e.reason) });
});

Use the capture phase for error so you see events even when something downstream stops propagation. And note that the error event also fires for failed resource loads — a broken image or a script that 404'd — where the target is an element rather than an error object. Those are worth recording separately; a missing bundle explains a great many "the page is just blank" reports.

Capturing the network

Two APIs to wrap, and one to use as a backstop.

fetch is the straightforward one. Wrap it, record method, URL, start time, and then the status and duration when the promise settles. Record rejections too — a network failure produces a rejected promise, not a response, and "the request never completed" is a meaningfully different report from "the request returned 500."

XMLHttpRequest still matters. Plenty of dependencies use it internally, and if you only wrap fetch you will get reports where the failing request is invisible. Patch open to stash the method and URL on the instance, then patch send and listen for loadend.

PerformanceObserver on the resource entry type is the backstop. It sees every request the browser made — including images, stylesheets, fonts, sendBeacon, and anything issued by a script that grabbed a reference to fetch before you patched it. It gives you timing and transfer size but no status codes or bodies, which makes it a complement to the wrappers rather than a replacement.

js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    record({ url: entry.name, type: entry.initiatorType,
             duration: entry.duration, size: entry.transferSize });
  }
}).observe({ type: 'resource', buffered: true });

That buffered: true is the important part: it hands you entries from before the observer existed, which claws back some of the early timeline you would otherwise have lost.

Bodies are the dangerous part

Request and response bodies are the single most useful thing you can attach to a bug report, and the single most likely thing to turn a bug report into a security incident. Bodies contain auth tokens, personal data, password reset links, and payment details.

The defensible default is off, with an explicit opt-in per project. In BugDump that opt-in is captureNetworkBodies, and it is deliberately not the default even though it makes reports better. If you build this yourself, make the same call.

When bodies are on, there are mechanical traps too. A Response body is a stream that can be consumed once — you must clone() before reading, or the application's own await res.json() will throw on an already-consumed body. Cap the size you read. And read asynchronously so you are not blocking the application's own handling of the response.

Redaction belongs in the browser

The strongest privacy property available to you is that sensitive data never leaves the machine. That means filtering at capture time, in the client, not on the server after upload.

Two filters cover most needs. A console filter drops entries by level or by pattern — useful for the library that logs a session token at debug on every render. A network filter drops or rewrites entries by URL, which is how you exclude your auth endpoint, your payment provider, and the analytics beacons that would otherwise be 90% of the log.

js
Bugdump.init({
  apiKey: 'your-api-key',
  networkFilter: (req) => !req.url.includes('/auth/'),
  consoleFilter: (entry) => entry.level !== 'debug',
});

Predicates beat configuration here. Any regex-based allowlist you design will be wrong for someone; a function is wrong for nobody.

Sizing, and knowing when to stop

A bug report is not an observability pipeline. You are capturing a few minutes of one session, not a fleet.

Ring buffers with hard caps — on the order of a few hundred console entries and a few hundred network entries — are almost always enough, because the useful window is the seconds before the user gave up. Keep the most recent entries and drop from the front. Record how many you dropped, so the report can say "showing the last 200 of 4,318 entries" instead of quietly implying the log was short.

The same logic applies to what you do not build. Source map resolution, log level configuration per module, sampling, retention policies, aggregation — those are the problems of an error monitoring product. A bug report has an author who is standing right there telling you what they were trying to do. That context does most of the work that aggregation does elsewhere.

The honest recommendation

Everything above is buildable in a few hundred lines. The reason it is worth understanding even if you do not build it is that these are the properties to check for in whatever you do use: Does it install before the app bundle? Does it buffer from load? Does it survive being patched by someone else? Are bodies off by default? Can you redact before upload?

BugDump does all of this behind one script tag, renders its UI inside a Shadow DOM so nothing leaks in either direction, and attaches the captured console and network logs to whatever the user types into the report box. The free tier covers 500 reports per project with no card.

But the capture layer is the part that matters, whoever writes it. The rest of the tool is just a way of getting it to you.