Skip to content
A Bekkour
Writing

Capturing a bug in the browser: wrapping fetch, XHR and console (and redacting PII first)

6 min read

A video of a bug shows you what happened. It doesn’t show you the 500 the page swallowed, or the TypeError in the console, or the request that went out with the wrong payload. To actually let someone replay a bug you need the console and network alongside the video — which means capturing the page’s own fetch, XMLHttpRequest, and console from the outside.

That’s monkey-patching, and it has two hard requirements. One: never break the page. Your capture runs inside someone else’s app; if it throws, or consumes a response body the app needed, you’ve caused a bug while trying to record one. Two: never exfiltrate secrets. You’re recording real users, so passwords, tokens, cookies and card numbers must be stripped client-side, before a byte leaves the browser. Here’s how I do both, in dependency-free TypeScript.

Wrapping fetch without eating the response

Save the original, replace window.fetch, and — this is the part people get wrong — if you read the response body, clone it first. A Response body is a one-shot stream; if you call .text() on the real one, the app gets an empty body. Clone, and only bother for error responses (a 200’s body is rarely what you need and is expensive to buffer):

originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const start = Date.now();
  const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
  const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
  const initiator = captureInitiator();

  try {
    const response = await originalFetch!(input, init);

    let responseBody: string | undefined;
    if (response.status >= 400) {
      const cloned = response.clone();          // <-- do NOT consume the app's body
      const text = await cloned.text();
      responseBody = redactString(text.slice(0, MAX_BODY_BYTES));
    }

    sink({
      ts: start, method, url: redactUrl(url), status: response.status,
      durationMs: Date.now() - start,
      requestHeaders: redactHeaders(requestHeaders),
      responseHeaders: redactHeaders(headersToObject(response.headers)),
      responseBody, initiator,
      timing: lookupResourceTiming(url, start),
      source: 'fetch',
    });
    return response;                            // always return the real, untouched response
  } catch (err) {
    sink({ ts: start, method, url: redactUrl(url), error: String(err), source: 'fetch' });
    throw err;                                  // failures must still propagate to the app
  }
};

Everything about that shape is deliberate: the real response is always returned, errors are always re-thrown, and the recording is a side effect that can’t change the app’s behaviour. The initiator is a nice touch — it’s the call site pulled out of a fresh Error().stack, so a mystery request can be traced back to the line that made it.

XHR: subclass instead of patching methods

Plenty of apps (and libraries) still use XMLHttpRequest. There’s nothing to clone here — responseText is re-readable, so you’re not at risk of consuming the body — you just subclass, override open and send (and setRequestHeader, which I’ve left out of the snippet, to capture request headers), and hang a loadend listener to record the result:

const ProxiedXHR = class extends originalXHR {
  private _method = 'GET';
  private _url = '';
  private _start = 0;

  open(method: string, url: string | URL, ...rest: unknown[]) {
    this._method = method;
    this._url = url.toString();
    return super.open(method, url, ...rest);
  }

  send(body?: Document | XMLHttpRequestBodyInit | null) {
    this._start = Date.now();
    this.addEventListener('loadend', () => {
      sink({
        ts: this._start, method: this._method, url: redactUrl(this._url),
        status: this.status || undefined,
        durationMs: Date.now() - this._start,
        source: 'xhr',
      });
    });
    return super.send(body);
  }
};
window.XMLHttpRequest = ProxiedXHR;

Subclassing keeps every default behaviour intact — you’re adding a listener and some bookkeeping, not reimplementing the request.

Console, plus the errors that never reach console

Wrapping console follows the same “always call through” rule — capture the entry, then invoke the real method so the developer’s own logging still works. For error/warn I also grab a stack so the entry has a source location:

for (const level of LEVELS) {
  originals[level] = console[level].bind(console);
  console[level] = (...args) => {
    try {
      const entry = { ts: Date.now(), level, args: args.map(safeSerialize).map((v) => redactValue(v)) };
      if (level === 'error' || level === 'warn') {
        const stack = redactString(new Error().stack || '');
        entry.stack = stack;
        entry.source = parseSource(stack);
      }
      sink(entry);
    } catch { /* capture must never throw into the app */ }
    originals[level]?.(...args);
  };
}

But the most useful errors often never touch console — an uncaught exception, a rejected promise nobody handled, a failed script/image, a CSP violation. Those come from window/document events:

window.addEventListener('error', errorListener, true);          // uncaught + resource load errors
window.addEventListener('unhandledrejection', rejectionListener); // dropped promise rejections
document.addEventListener('securitypolicyviolation', cspListener); // CSP blocks

The error listener with capture: true catches resource failures (a broken <img>/<script>) that don’t bubble, which are exactly the “why is this blank” bugs a screenshot can’t explain.

Redaction happens at the source

This is the part that earns trust. Everything above passes through one redaction module before it’s handed to the sink, so secrets never make it into the captured data at all. It works three ways.

Key-aware, for structured data — a password/token/cvv field is blanked whether it’s JSON or a key=value pair, and objects are walked recursively (with a depth cap so a cyclic-ish structure can’t hang the page):

export function redactValue(value: unknown, depth = 0): unknown {
  if (depth > 6) return value;
  if (typeof value === 'string') return redactString(value);
  if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
  if (typeof value === 'object' && value) {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value)) {
      out[k] = SENSITIVE_KEYS.includes(k.toLowerCase()) ? REDACTED : redactValue(v, depth + 1);
    }
    return out;
  }
  return value;
}

Pattern-based, for secrets that show up in free text regardless of the key around them — card numbers, bearer tokens/JWTs, provider key prefixes (sk_live, ghp, xoxb…), SSNs, emails:

const PATTERNS = [
  { name: 'credit-card', regex: /\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b/g },
  { name: 'credit-card-amex', regex: /\b\d{4}[ -]\d{6}[ -]\d{5}\b/g },
  { name: 'bearer', regex: /\b(?:Bearer|Token|JWT)\s+[A-Za-z0-9._\-+/=]{20,}/gi },
  { name: 'api-key-prefix', regex: /\b(?:sk_live|sk_test|whsec|ghp|gho|xoxb|xoxp)[_-][A-Za-z0-9._\-+/=]{16,}\b/gi },
  { name: 'sin-ssn', regex: /\b\d{3}[-\s]\d{2,3}[-\s]\d{3,4}\b/g },
  { name: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
];

And header/URL-aware, because tokens love to hide in Authorization/Cookie headers and in query strings — redactUrl blanks sensitive query params and falls back to string redaction if the URL won’t parse. Bodies are truncated to a cap (MAX_BODY_BYTES) so a huge payload can’t bloat the recording, and only write requests capture a request body at all.

The rules that keep it safe

Two short lists, and everything else follows:

Don’t break the page. Always call through to the original. Wrap every capture in try/catch so a serialization bug can’t take down the app. Clone responses; never consume them. Re-throw errors. Restore the originals on stop — subclass or keep the reference, so teardown is exact:

export function stopNetworkCapture(): void {
  if (originalFetch) window.fetch = originalFetch;
  if (originalXHR) window.XMLHttpRequest = originalXHR;
}

Don’t leak secrets. Redact at the source, before the sink, in three ways (keys, patterns, headers/URLs), with a recursion cap and body truncation. When in doubt, redact — a [REDACTED] in a bug report is a non-event; a customer’s card number in your storage is an incident.

Monkey-patching has a bad reputation, and most of the time it’s earned. But done with discipline — call through, catch everything, restore cleanly, and treat every captured byte as radioactive until it’s been redacted — it’s the honest way to record what a page actually did.


This is the capture layer of Constat, the bug-capture tool I’m building — a customer clicks one link and you get the video, console, and network to replay the repro. If you’re doing browser instrumentation or care about getting client-side redaction right, I’m happy to compare notes. The server side — streaming these recordings to storage in Go — is its own post.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing