The module-init call-order contract
On this page
This is the part that is easy to get wrong. Capture functions must be
called where the thing they wrap is constructed, at module scope — not from
inside a component body or a useEffect.
If you call them from a component, capture only starts once React mounts, and every request fired before that point — auth bootstrap, session restore, prefetches, anything at module-eval time — is silently missed. The panel then shows a log with a hole at the beginning, which is exactly when you most need it.
Every one of these is a no-op in production, but the call sites still cost you bytes unless you guard them. See Guard your call sites — it is one line per site and it is the difference between shipping the capture runtime and not.
HTTP — attachHttpMonitor(instance, options?)
Call it after your own interceptors are registered. That one placement is what puts Blix on the plaintext side of both legs of the request, for two different reasons:
- On the way out, axios runs request interceptors LIFO, so registering last makes Blix's interceptor run first — before any encryption or serialization step. It sees the plaintext body.
- On the way back, axios runs response interceptors FIFO, so registering last makes Blix's interceptor run last — after your decrypt interceptor. It sees the decrypted body.
The two orders are opposite, and they happen to agree on the same answer: register last.
// src/network/axios.tsimport axios from "axios";import { attachHttpMonitor, withInitiatorCapture } from "@hakam-aldeen-kh/blix/capture"; export const apiClient = axios.create({ baseURL: "/api" }); apiClient.interceptors.request.use(addAuthHeader);apiClient.interceptors.request.use(encryptBody); // After your interceptors, at module scope — NOT in a hook, NOT in a component.if (process.env.NODE_ENV === "development" && typeof window !== "undefined") { attachHttpMonitor(apiClient);}attachHttpMonitor is idempotent per instance — a second call on the same
instance, or on its withInitiatorCapture wrapper, does nothing — and it
returns a disposer that ejects both interceptors, for HMR and tests. A request
cancelled through an AbortController or a CancelToken settles as
aborted, not as an error; a timeout is still an error.
Factory and lazy-singleton clients
The rule is causal, not positional. "Bottom of the module" is shorthand that only holds when your interceptors are registered by statements physically above the call. If your instance comes from a factory or a lazy singleton, the interceptors are registered inside that factory, on first call — so what matters is that something has already triggered construction:
// ApiClientFactory.getInstance() registers the interceptors on its first call.export const apiClient = withInitiatorCapture(ApiClientFactory.getInstance()); // Safe: getInstance() ran on the line above, so the interceptors exist by now.if (process.env.NODE_ENV === "development" && typeof window !== "undefined") { attachHttpMonitor(apiClient);}If nothing above the call has constructed the instance, attachHttpMonitor
registers first rather than last, and you get the silent wrong reading
described above. There is no need for queueMicrotask, setTimeout or any
other deferral: Blix does not require one, and deferring only hides whether the
ordering is actually correct.
Failed requests and the shape of your rejection
Blix records how a request ended at the wire: before the first of your
response interceptors runs, while the value is still the AxiosError — or the
response — that axios produced. So a response interceptor that normalises
errors into your own domain type no longer costs you the entry:
// Fine since 0.11: the failure was recorded before this raninstance.interceptors.response.use(undefined, (error) => Promise.reject(error.response?.data ?? fallbackError),);(Through 0.10 this left every non-2xx request pending for the rest of the
session, because the value Blix received had no config to correlate by.)
The entry carries the real failure — status, response body, the client's code.
What your interceptor handed the caller instead is recorded beside it as
error.handledAs, whenever Blix can tie the two together: your error keeps the
original on cause, response, originalError or error, or still carries
config. An error with no link back to the original is simply not shown; the
entry is still settled correctly.
Three limits:
- An error thrown by a request interceptor never reaches the wire. It is attributed to its request when that is the only one in the air; with several it is left to the timeout watchdog below rather than guessed at.
- A request with a
timeoutthat is still pending 3 s past it is settled as a timeout that says Blix saw no answer — and corrected if one arrives after all. A request with notimeoutthat never answers stayspending: nothing times it out. - The wire is observed by wrapping the first response interceptor already on
the instance. On an axios whose internals do not have the shape Blix expects
it is skipped, and the behaviour above falls back to correlating by
error.configalone.
Response interceptors that unwrap
The same holds on the success path. A response interceptor registered before
Blix that returns something other than the response —
(response) => response.data is the usual one — used to leave the entry
pending. The response is now recorded at the wire, status and headers
included, before the interceptor unwraps it:
- your request is unaffected: your code receives exactly what your interceptor returned. (Before 0.7.0 Blix threw at this point, and every request on the instance failed.)
- the Response tab shows the body your code received — the unwrapped, decrypted one — when Blix can recognise it as the same object, and the body as it came off the wire otherwise.
Which request headers the panel shows
Blix's request interceptor runs before yours, so when it runs, the headers your
interceptors add — auth, tracing, signing, locale — do not exist yet, and
neither does the Content-Type axios sets itself. So the panel shows the
headers the request settled with, re-read from the config axios hands back
with the response or the error, and masked the same way. Until then — and for
good, for a request that never settles or whose rejection carries no config —
it shows the snapshot taken when the request started. JWT claims follow the same
rule.
Some headers never appear, because axios's adapter adds them to its own copy of the config, past the last point Blix can read:
Authorization: Basic …from theauthoption. Whenauthis set, the panel keeps theAuthorizationit saw when the request started: the settled config still shows whatever your interceptors set, which is not what was sent. Every other header is still taken from the settled config.- The XSRF header (
X-XSRF-TOKENby default). - Headers the browser manages itself —
Cookie,Origin,User-Agentand the like. Chrome's Network tab shows them; JavaScript never sees them.
Encrypted payloads — captureEncrypted(config, payload)
Since 0.3.0.
Entirely optional. An app that never calls it behaves exactly as it did before this API existed, and its panel shows no Encrypted tab at all — the tab appears only on entries that actually carry ciphertext.
Blix cannot capture the encrypted forms by itself. It has no knowledge of your
encryption scheme, and — by the design above — its interceptor deliberately
sits on the plaintext side, so at the moment Blix captures, the ciphertext
does not exist yet. captureEncrypted is the hand-off: you call it from inside
your own interceptors, where the ciphertext does exist, and pass back the same
config object Blix already saw.
Two calls, one on the way out and one on the way back:
// src/network/axios.tsimport { attachHttpMonitor, captureEncrypted } from "@hakam-aldeen-kh/blix/capture"; apiClient.interceptors.request.use((config) => { const encrypted = encryptBody(config.data); captureEncrypted(config, { request: encrypted }); return { ...config, data: encrypted };}); apiClient.interceptors.response.use((response) => { captureEncrypted(response.config, { response: response.data }); return { ...response, data: decryptBody(response.data) };});Both calls work on the copies those interceptors return, not on the objects
axios created — see How correlation works for why
that still resolves. On the response side it is response.config that has to
carry the stamp, and it does: axios threads the object returned by the last
request interceptor straight through to response.config, so the
{ ...config, data: encrypted } above is literally the object you get back.
Dropping it into an interceptor you already have
The example above builds a fresh interceptor that returns a spread copy. Most
real pipelines have one large multi-step interceptor that mutates config.data
in place. captureEncrypted needs no restructuring for that — it is two lines:
private static async encryptRequest(config) { const encrypted = await encryptionService.encryptApiPayload( JSON.stringify(config.data), key, ); captureEncrypted(config, { request: encrypted }); // ← add config.data = encrypted; return config;} private static async decryptResponse(response) { captureEncrypted(response.config, { response: response.data }); // ← add, BEFORE decrypting return { ...response, data: await encryptionService.decryptApiResponse(response) };}Note the placement on the response side: call it before you decrypt, so the value you hand over is the wire form. Calling it after decryption puts your plaintext response — secrets included — under a tab labelled Encrypted, which is both wrong and a disclosure. See the redaction note below.
Call it only where encryption actually happened
The examples above call captureEncrypted unconditionally, which is only
correct because they have no path that skips encryption. Real pipelines do: an
endpoint on an exclusion list, an explicit skipEncryption flag, a missing or
not-yet-derived session key, a FormData body carrying only file parts, an
encryption failure the interceptor swallows so the request can still go out.
On any of those paths the value you would hand over is plaintext, and Blix has no way to know that — it labels whatever you pass as the encrypted wire form and shows it under the Encrypted tab. The result is a reading that looks authoritative and is wrong, which is worse than no reading at all.
So put the call on the success path, inside the function that encrypts — next to the line that produced the ciphertext, where it cannot outlive the condition that made it true — rather than in the interceptor after the function returns:
// Inside your encryption module, not in the interceptor.function encryptBody(config) { if (shouldSkip(config)) return config; // no call — nothing encrypted const key = sessionKey(); if (!key) return config; // no call — nothing encrypted try { const encrypted = seal(config.data, key); captureEncrypted(config, { request: encrypted }); // only here config.data = encrypted; } catch { // Encryption failed and we're sending plaintext — deliberately no call. } return config;}The same rule governs the response side: call it only where you know the body you are holding is the pre-decryption wire form.
How correlation works
Requests and responses are correlated by the identity of the config object,
never by URL or timing, so two concurrent calls to the same endpoint stay
correctly apart. Pass the object axios handed you; a { ...config } copy made
by your own interceptor resolves too.
The copy resolves because there are two stamps, not one:
The symbol is the primary; __monitorId is what the resolver falls back to,
and it is the same property attachHttpMonitor has set since 0.2.1 for its own
response interceptor. A third fallback, a WeakMap, covers only a frozen or
sealed config that rejects defineProperty.
Two consequences worth being explicit about. Neither stamp reaches the
wire — they live on the axios config, and axios serializes only data. But
__monitorId is enumerable, so it will appear if you log or stringify the
config object itself; only the symbol is fully invisible. And a copy that
enumerates fields explicitly — { url, method, data: encrypted } rather
than a spread — carries neither stamp and will not resolve, making the call a
silent no-op. Spread, or mutate in place.
The two calls are independent and order-free: the request-side ciphertext is produced early and the response-side arrives late, possibly after Blix has already finalized the entry. Either way it merges into the existing entry — never creating one of its own — and the panel updates.
It is a silent no-op — never a throw, never a console warning — in every one of these:
- outside development (
process.env.NODE_ENV !== "development"), unless the build opted in withNEXT_PUBLIC_BLIX=true(see staging); - outside the browser — the gate is also
typeof window !== "undefined", so every call made while rendering on the server does nothing, by design (see Production elimination); - when
attachHttpMonitorwas never called on the instance; - when the config carries no stamp — a retry that built a fresh config, a config assembled field-by-field rather than spread, or a request that started while capture was paused from the panel's toolbar;
- when
payloadis missing, or bothrequestandresponseon it arenull/undefined(a bare{ request }will not blank aresponsecaptured by an earlier call); - when the entry has already been evicted from the buffer.
In production it is eliminated entirely, along with the rest of capture.
Values may be a string, a plain object, or an ArrayBuffer/typed array (kept
as a bounded hex preview plus byte length). They go through the same
serialization and truncation rules as the plaintext bodies, both in the panel
and in IndexedDB, so a multi-megabyte ciphertext cannot blow out the log.
withInitiatorCapture(instance)
Optional. Wraps the instance in a Proxy so each request records the stack of
its own call site, which the panel shows as the "Initiator" of a row. Wrap
once, export the wrapped instance:
export const apiClient = withInitiatorCapture(axios.create({ baseURL: "/api" }));It does not matter which of the two you hand to attachHttpMonitor. The
proxy forwards property reads to the underlying instance, and interceptors is
not a function, so it comes back untouched — wrapped.interceptors and
original.interceptors are the same object. Registering on either registers on
both. There is no wrong choice here and no silent failure.
What does matter is which one your app calls through. Only the proxy's traps record a stack, so every request made against the unwrapped instance is still captured but arrives with no initiator. Export the wrapped one and keep the original private:
const client = axios.create({ baseURL: "/api" });client.interceptors.request.use(encryptBody);attachHttpMonitor(client); // either one works export const apiClient = withInitiatorCapture(client); // this is what callers useThe traps cover the callable form (apiClient(config)) plus request, get,
post, put, patch, delete and head. Other entry points — options,
the *Form helpers — pass through unwrapped: still captured, just with no
initiator stack.
HTTP (fetch) — attachFetchMonitor(options?)
For requests made with fetch directly rather than through axios. It wraps
globalThis.fetch, so it captures every fetch the page makes — your own
and any library's or third-party script's — into the same Network section, in
the same shape, with the same header masking and the same absence of body
redaction (see Security). Its rows show Client: fetch in the
Headers tab, and client:fetch filters to them. Each row records the call
stack of the fetch call that made it; no withInitiatorCapture is needed.
// instrumentation-client.ts (Next.js 15.3+), or the first module your client bundle evaluatesimport { attachFetchMonitor } from "@hakam-aldeen-kh/blix/capture"; if (process.env.NODE_ENV === "development" && typeof window !== "undefined") { attachFetchMonitor();}Call it before the first fetch you want captured. There are no
interceptors to order against, but anything fetched before the wrapper is
installed is simply not seen. In a Next.js App Router project,
instrumentation-client.ts runs on the client before your application code and
is the natural place; otherwise, the top of the first module your client bundle
evaluates.
Client-side only. It never patches fetch on the server, so Next.js's
server-side fetch and its data cache are untouched — and like every capture
function, it is a no-op in production.
It returns a disposer that restores the original fetch, and calling
attachFetchMonitor again while it is installed does nothing. The disposer
only restores fetch while Blix's wrapper is still the current one: if another
library has wrapped fetch since, restoring would silently remove that wrapper
too, so it leaves fetch alone and warns in development instead.
Bodies, streams and the size cap
Blix reads bodies from clones, so your code always receives an untouched
Request and Response. It never buffers without a limit:
A skipped body is recorded as «body not captured: …» with the reason, so an
empty Response tab always means an empty response. The cap applies to request
and response bodies alike, defaults to 5 MB, and is set with
maxBodyBytes:
attachFetchMonitor({ maxBodyBytes: 1024 * 1024 }); // 1 MB — 0 captures no bodiesThe cap is enforced by counting bytes as they arrive, not by trusting
Content-Length. That header is the compressed size, and a small gzipped
response can decode to many times it; a declared length over the cap only lets
Blix skip without starting. A row's duration runs to the end of the body when
Blix read it, and to the response headers when it did not.
A non-2xx response is recorded as a failed request with its body under
Error, the same way an axios rejection is. An aborted request settles as
aborted; a network failure or an AbortSignal.timeout() settles as an
error. Either way the original rejection reaches your code untouched.
What is left out by default
A development server makes plenty of fetch calls of its own, and they would
bury your requests. These are not captured by default:
A string matches anywhere in the URL's path and query, a regular expression is
tested against the same string, and a function receives the resolved URL.
Pass an array to replace the defaults, or a function to extend them:
attachFetchMonitor({ ignore: (defaults) => [...defaults, "/api/health"] }); // extendattachFetchMonitor({ ignore: [] }); // capture everythingAn ignored request is dropped silently — no row records that it was skipped. If a request you expected is missing, check it against this list first.
Using it alongside axios
Installing both attachFetchMonitor and attachHttpMonitor is fine. axios's
default browser adapter is XHR, which the fetch wrapper never sees. If you
configure axios with adapter: "fetch", one request passes through both — and
the axios entry wins. It was captured on the plaintext side of your
interceptors, keeps captureEncrypted and withInitiatorCapture, and can be
replayed; the fetch wrapper would only see the same request after encryption.
attachHttpMonitor marks each request on its way into axios's fetch adapter,
and the fetch wrapper skips anything carrying the mark, so every request is
logged exactly once. The order of the two calls does not matter.
What it does not do
- No replay. Fetch rows show Replay disabled, with the reason. Blix does
not record a call's
credentials,modeorcache, or a JSON body as the exact bytes that were sent, so a replay could not promise to be the same request. - No
captureEncrypted— see Encrypted payloads. - No timeout watchdog. A
fetchthat never answers stays pending until the page aborts it: afetchhas no timeout of its own for Blix to measure against. (AnAbortSignal.timeout()that fires is recorded, as a timeout.) axios differs: a request with atimeoutis settled 3 s past it — see Failed requests.
What a request records — entry.error and entry.wire
Two fields of a captured entry are worth knowing by shape, because they are
what the JSON and NDJSON exports carry and what the MonitorEntry type
describes. Both are the same whichever client made the request.
entry.error — CapturedError
A failed request stores a description of the failure, not the thrown object and not just the response body:
When there was no response, the status column reads the kind — timeout or
network — and the detail head shows the failure's own message.
Query and mutation rows, and a Redux action whose payload is an error, store the same shape. Only named fields are ever read from an error: never its request config or headers.
entry.wire — WireTiming
Everything else Blix times is two clock reads in JavaScript. wire is what the
browser measured for the same request — its PerformanceResourceTiming
entry — attached automatically by every attach*Monitor, and shown in the
Timing tab under On the wire and Server-Timing:
For a cross-origin request the browser withholds the phases and the sizes
unless the response carries Timing-Allow-Origin. Such an entry has
restricted: true and totalMs only — add the header on the API to get the
rest. An entry has no wire at all when the browser reported no timing for
it, or when Blix could not match one to the request.
The Size column uses the browser's decoded size when it reports one.
Redux — createReduxMonitorMiddleware(options?)
// src/store.tsimport { configureStore } from "@reduxjs/toolkit";import { createReduxMonitorMiddleware } from "@hakam-aldeen-kh/blix/capture"; const devMiddleware = process.env.NODE_ENV === "development" && typeof window !== "undefined" ? createReduxMonitorMiddleware({ ignore: ["analytics/*", "some/noisyAction"], }) : undefined; export const store = configureStore({ reducer, middleware: (getDefault) => devMiddleware ? getDefault().concat(devMiddleware) : getDefault(),});Options: ignore (exact types or "prefix/*" globs), coalesceMs (repeat
dispatches of one type inside this window fold into a single row), and
maxActionsPerSecond (above this rate, capture drops to type + timing and
skips diffing). Outside development the factory returns a pure pass-through
middleware, so calling it unguarded is behaviourally free — but see
Guard your call sites for why the guard above is
still worth the extra three lines.
Re-dispatch sends a captured action into the live store again, and needs
the store prop (see Props). It is disabled, with the
reason, for an action Blix did not store as it was dispatched: one whose
payload was truncated, is an error — stored as a
CapturedError — or holds a
masked key.
Realtime — tapRealtimeAdapter(adapter, transport)
Returns the adapter wrapped; use the return value. transport is a free-form
label shown in the panel ("pusher", "socket.io", …).
// src/realtime/adapter.tsimport { tapRealtimeAdapter } from "@hakam-aldeen-kh/blix/capture"; const adapter = new PusherAdapter(); export const realtime = process.env.NODE_ENV === "development" && typeof window !== "undefined" ? tapRealtimeAdapter(adapter, "pusher") : adapter;Your adapter only needs to structurally satisfy RealtimeAdapterLike:
connect, disconnect, subscribe, onMessage, onPresenceUpdate. In
production the adapter is returned untouched.
The tap is transparent, not narrowing. It returns a Proxy typed as your
adapter's own type, so class-based adapters keep working through it:
- methods outside
RealtimeAdapterLikepass straight through — the proxy's fallback binds and forwards any property that is not one of the tapped methods; instanceofstill works against your concrete class, because the proxy has nogetPrototypeOftrap and forwards to the target.
// Both of these still work through the tap.if (this.adapter instanceof ActionCableAdapter) { this.adapter.setSubscriptionContext({ accountId, userId }); // not in RealtimeAdapterLike}You do not need to keep a second reference to the untapped adapter.
TanStack Query — tapQueryClient(client)
tapQueryClient is the one exception to the module-scope rule. Call it
from an effect in your query provider, not from the useState initializer
that creates the client:
"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { tapQueryClient } from "@hakam-aldeen-kh/blix/capture";import { useEffect, useState } from "react"; export function QueryProvider({ children }: { children: React.ReactNode }) { const [client] = useState(() => new QueryClient()); useEffect(() => tapQueryClient(client), [client]); return <QueryClientProvider client={client}>{children}</QueryClientProvider>;}Why the effect at all: React Strict Mode double-invokes useState
initializers, so tapping there taps a client that is immediately discarded.
Installing from an effect is correct — because parent effects run after child
effects, the tap backfills from getQueryCache().getAll() on install rather
than starting blind, so no early events are lost.
Returning the disposer is fine: it unsubscribes from both caches and unmarks the client, so the Strict Mode cycle — subscribe, dispose, subscribe again — reinstalls cleanly and the second install backfills the same way the first did.
If you construct the QueryClient at module scope rather than in a component,
you can tap it at module scope too — the rule is "tap the client that actually
survives", which in the common React pattern means an effect.