fix: capture XMLHttpRequest and console.error (v0.6.0) #36

Merged
john merged 2 commits from feat/fix-xhr-and-console-error-capture into dev 2026-07-30 01:09:57 +00:00
Owner

Problem

The SDK has three capture channels. Two were blind to any app built on axios.

Channel Was patched Missing
Errors (ErrorBuffer) window.onerror, unhandledrejection
Console (ConsoleBuffer) log, warn, info error
Network (NetworkInterceptor) globalThis.fetch XMLHttpRequest

axios uses XMLHttpRequest and never touches fetch, so every backend call in
an axios app was invisible to the interceptor — failedRequests came back empty.
React Query and most hand-written error handlers report through console.error,
so the console channel was empty too.

Together these are why BUG-515 in superclean-sys2 arrived with
errors: [], console: [], failedRequests: []. The reporter was not
mis-transmitting evidence; it could not see any.

Fix 1 — NetworkInterceptor patches XMLHttpRequest

XMLHttpRequest.prototype.open / send are now patched alongside fetch. Both
transports feed the same getFailures() buffer and getRequestLog().

Decisions worth reviewing:

  • Finalize on loadend. It is the single terminal event — it fires after
    load, error, abort and timeout alike, so one listener covers every
    outcome rather than four that can disagree.
  • Aborts are logged but not counted as failures. An abort surfaces as
    status === 0 with no error event. React Query cancels in-flight queries on
    unmount and axios cancels on its own; counting those as failures would bury the
    real ones under routine noise. Transport failures (error / timeout) are
    counted.
  • Status-0 failures carry a marker body ([xhr network error] /
    [xhr timeout]). A status-0 entry with an empty body tells a reader nothing,
    and it is worth being able to tell a timeout from a refused connection.
  • responseText is read only when responseType permits it. It throws a
    DOMException for any responseType other than '' or 'text';
    responseType: 'json' reads response and stringifies instead. Everything
    else records an empty body rather than throwing inside the patch.
  • Never break the app. Both patches wrap instrumentation in try/catch and
    always call through to the original. destroy() restores both prototype
    methods on the constructor it patched (stored, not re-read from the global).
  • Per-request state lives in a WeakMap, so nothing is retained after the XHR
    is collected.

Fix 2 — ConsoleBuffer patches console.error

Rather than a fourth near-identical copy-pasted block, the patched levels are now
driven by a single PATCHED_LEVELS list with 'error' added. The bug was a
missing entry in a hand-maintained set of four; one list is what stops it
recurring. destroy() restores from a map, so the restore path can't drift from
the patch path either.

One related fix while in there: console.error('request failed:', err) used to
record request failed: {}. JSON.stringify(new Error('boom')) is '{}'
an Error has no enumerable own properties — so the previous formatting dropped
the only useful part of the most common error-logging call shape. Errors now
format as message + stack.

The test suite was entirely non-functional

Worth flagging separately, because it is why this shipped.

  • vitest had no jsdom environment configured. Every capture module touches
    window, document or XMLHttpRequest, so all 31 existing tests died on
    ReferenceError: window is not defined.
  • CI never ran pnpm test at all — the build job was lint + typecheck + build.

So the suite was both broken and unobserved. Fixed: added jsdom +
test.environment in vite.config.ts, and a Test step after Build in
ci.yml (after, because tests/integration/build-output.test.ts asserts on
dist/).

Suite is now 63 passing, up from 0.

Red-green verified

26 new tests across the two modules. Reverted both source files to their
origin/dev state and re-ran: 22 of the 26 fail, then pass with the fix.
They test the bug, not the implementation.

Coverage includes: 4xx and 5xx capture, request-log entries, success not
recorded as failure, transport error vs timeout vs abort, method uppercasing,
URL objects, JSON responseType, unreadable responseText, 1KB truncation,
30-entry buffer cap, pass-through to the real send(), destroy() restore,
init() idempotence, both channels feeding one buffer, and environments with no
XMLHttpRequest.

Downstream — superclean-sys2 workaround can be removed

superclean-sys2 PR #423 added an axios-level workaround for exactly this
blind spot: api-error-log.ts plus a console.warn mirror, so that axios
failures would reach a channel the SDK could actually see. That workaround can
be removed once this ships
— bump @haskytech/bug-reporter to 0.6.0 there
and delete both.

Recommend keeping it until the version bump lands, then removing in one commit
so there is never a window with no error capture.

Scope callout

The ask was two fixes. Two things beyond it:

  1. Test infrastructure (jsdom + CI test step). Without it, new tests are
    inert — they cannot run locally and CI would never execute them. Adding tests
    to a suite that does not run would have been decoration.
  2. ConsoleBuffer loop refactor and the Error formatting fix. The first
    removes the copy-paste that caused the bug; the second is the same class of
    blind spot in the channel being fixed.

Happy to split either out if you would rather review them separately.

Verification

pnpm lint          clean
pnpm exec tsc      clean
pnpm build         12.85 kB gzip ESM (constraint: <15 kB)
pnpm test          63 passed (6 files)

Docs updated: README.md (capture-channel table), docs/contracts/api-surface.yaml,
docs/strategy/constraints.md, docs/strategy/posture.md, docs/constraints.md,
docs/ops/key_info.md.

Version bumped to 0.6.0 — additive ConsoleMessage.level union widening
('error' added) and a new exported ConsoleLevel type.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q6VkoRihmoCUvXXYy8odKW

## Problem The SDK has three capture channels. Two were blind to any app built on axios. | Channel | Was patched | Missing | |---|---|---| | Errors (`ErrorBuffer`) | `window.onerror`, `unhandledrejection` | — | | Console (`ConsoleBuffer`) | `log`, `warn`, `info` | **`error`** | | Network (`NetworkInterceptor`) | `globalThis.fetch` | **`XMLHttpRequest`** | axios uses `XMLHttpRequest` and never touches `fetch`, so every backend call in an axios app was invisible to the interceptor — `failedRequests` came back empty. React Query and most hand-written error handlers report through `console.error`, so the console channel was empty too. Together these are why **BUG-515** in superclean-sys2 arrived with `errors: [], console: [], failedRequests: []`. The reporter was not mis-transmitting evidence; it could not see any. ## Fix 1 — `NetworkInterceptor` patches `XMLHttpRequest` `XMLHttpRequest.prototype.open` / `send` are now patched alongside `fetch`. Both transports feed the same `getFailures()` buffer and `getRequestLog()`. Decisions worth reviewing: - **Finalize on `loadend`.** It is the single terminal event — it fires after `load`, `error`, `abort` and `timeout` alike, so one listener covers every outcome rather than four that can disagree. - **Aborts are logged but not counted as failures.** An abort surfaces as `status === 0` with no `error` event. React Query cancels in-flight queries on unmount and axios cancels on its own; counting those as failures would bury the real ones under routine noise. Transport failures (`error` / `timeout`) *are* counted. - **Status-0 failures carry a marker body** (`[xhr network error]` / `[xhr timeout]`). A status-0 entry with an empty body tells a reader nothing, and it is worth being able to tell a timeout from a refused connection. - **`responseText` is read only when `responseType` permits it.** It throws a `DOMException` for any responseType other than `''` or `'text'`; `responseType: 'json'` reads `response` and stringifies instead. Everything else records an empty body rather than throwing inside the patch. - **Never break the app.** Both patches wrap instrumentation in try/catch and always call through to the original. `destroy()` restores both prototype methods on the constructor it patched (stored, not re-read from the global). - Per-request state lives in a `WeakMap`, so nothing is retained after the XHR is collected. ## Fix 2 — `ConsoleBuffer` patches `console.error` Rather than a fourth near-identical copy-pasted block, the patched levels are now driven by a single `PATCHED_LEVELS` list with `'error'` added. The bug *was* a missing entry in a hand-maintained set of four; one list is what stops it recurring. `destroy()` restores from a map, so the restore path can't drift from the patch path either. One related fix while in there: `console.error('request failed:', err)` used to record `request failed: {}`. `JSON.stringify(new Error('boom'))` is `'{}'` — an `Error` has no enumerable own properties — so the previous formatting dropped the only useful part of the most common error-logging call shape. Errors now format as message + stack. ## The test suite was entirely non-functional Worth flagging separately, because it is why this shipped. - `vitest` had **no jsdom environment** configured. Every capture module touches `window`, `document` or `XMLHttpRequest`, so all 31 existing tests died on `ReferenceError: window is not defined`. - CI never ran `pnpm test` at all — the `build` job was lint + typecheck + build. So the suite was both broken and unobserved. Fixed: added `jsdom` + `test.environment` in `vite.config.ts`, and a `Test` step after `Build` in `ci.yml` (after, because `tests/integration/build-output.test.ts` asserts on `dist/`). **Suite is now 63 passing, up from 0.** ### Red-green verified 26 new tests across the two modules. Reverted both source files to their `origin/dev` state and re-ran: **22 of the 26 fail**, then pass with the fix. They test the bug, not the implementation. Coverage includes: 4xx and 5xx capture, request-log entries, success not recorded as failure, transport error vs timeout vs abort, method uppercasing, `URL` objects, JSON responseType, unreadable `responseText`, 1KB truncation, 30-entry buffer cap, pass-through to the real `send()`, `destroy()` restore, `init()` idempotence, both channels feeding one buffer, and environments with no `XMLHttpRequest`. ## Downstream — superclean-sys2 workaround can be removed superclean-sys2 **PR #423** added an axios-level workaround for exactly this blind spot: `api-error-log.ts` plus a `console.warn` mirror, so that axios failures would reach a channel the SDK could actually see. **That workaround can be removed once this ships** — bump `@haskytech/bug-reporter` to `0.6.0` there and delete both. Recommend keeping it until the version bump lands, then removing in one commit so there is never a window with no error capture. ## Scope callout The ask was two fixes. Two things beyond it: 1. **Test infrastructure** (jsdom + CI test step). Without it, new tests are inert — they cannot run locally and CI would never execute them. Adding tests to a suite that does not run would have been decoration. 2. **`ConsoleBuffer` loop refactor** and the **Error formatting fix**. The first removes the copy-paste that caused the bug; the second is the same class of blind spot in the channel being fixed. Happy to split either out if you would rather review them separately. ## Verification ``` pnpm lint clean pnpm exec tsc clean pnpm build 12.85 kB gzip ESM (constraint: <15 kB) pnpm test 63 passed (6 files) ``` Docs updated: `README.md` (capture-channel table), `docs/contracts/api-surface.yaml`, `docs/strategy/constraints.md`, `docs/strategy/posture.md`, `docs/constraints.md`, `docs/ops/key_info.md`. Version bumped to **0.6.0** — additive `ConsoleMessage.level` union widening (`'error'` added) and a new exported `ConsoleLevel` type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q6VkoRihmoCUvXXYy8odKW
fix: capture XMLHttpRequest and console.error (v0.6.0)
Some checks failed
CI / Backend (pull_request) Has been cancelled
CI / Docker Build (pull_request) Has been cancelled
Docs Gate / docs-ok (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
3a5e39e747
Two of the three capture channels were blind to any app built on axios.

NetworkInterceptor patched `globalThis.fetch` only. axios uses XMLHttpRequest,
so every backend call in an axios app was invisible and `failedRequests` came
back empty. ConsoleBuffer patched log/warn/info but not error — React Query and
most hand-written error handlers report through `console.error`, so the console
channel was empty too.

Together these are why BUG-515 in superclean-sys2 arrived with
`errors: [], console: [], failedRequests: []` — the reporter could not see
anything.

NetworkInterceptor:
- Patch XMLHttpRequest.prototype.open/send alongside fetch. Both transports
  feed the same failure buffer and request log.
- Records method, url, status, duration; captures response bodies subject to
  the existing 1KB byte-truncation.
- Finalizes on `loadend` — the single terminal event for load, error, abort and
  timeout alike.
- Transport failures land as status 0 with a `[xhr network error]` /
  `[xhr timeout]` marker body, so a status-0 entry is not silently
  indistinguishable from an empty response.
- Aborts are logged but not counted as failures. React Query and axios cancel
  in-flight requests routinely; counting those would bury the real failures.
- `responseText` is only read when responseType permits it (it throws a
  DOMException otherwise); responseType 'json' reads `response` instead.
- Instrumentation is wrapped so a capture failure can never break open()/send().
- destroy() restores both prototype methods on the constructor it patched.

ConsoleBuffer:
- Patched levels are now driven by one PATCHED_LEVELS list with 'error' added,
  replacing four near-identical copy-pasted blocks. A missing level was the bug;
  a single list is what stops it recurring.
- Error arguments now format as message + stack. JSON.stringify(new Error('x'))
  is '{}' — an Error has no enumerable own properties, so the previous
  formatting dropped the only useful part of `console.error('failed:', err)`.

Test infrastructure — the suite was entirely non-functional:
- vitest had no jsdom environment, so all 31 existing tests died on
  `ReferenceError: window is not defined`, and CI never ran `pnpm test` at all.
  Added jsdom + `test.environment` and a Test step after Build in ci.yml.
- 26 new tests for the two fixes. Verified red-green: 22 of them fail against
  the previous implementation. Suite is now 63 passing, up from 0.

Bundle is 12.85 kB gzipped, within the 15 kB constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6VkoRihmoCUvXXYy8odKW
fix: do not repeat Error message alongside its stack
Some checks failed
Docs Gate / docs-ok (pull_request) Has been cancelled
CI / Backend (pull_request) Has been cancelled
CI / Docker Build (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
7aa259d8c1
Error.stack already opens with "Name: message", so prefixing the message printed
it twice in every captured console.error entry. Falls back to "name: message"
when stack is absent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6VkoRihmoCUvXXYy8odKW
john merged commit b14908ca91 into dev 2026-07-30 01:09:57 +00:00
john deleted branch feat/fix-xhr-and-console-error-capture 2026-07-30 01:09:57 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
haskytech/bug-reporter!36
No description provided.