Skip to content
v0.12.0

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.

FunctionWhere to call itTiming
attachHttpMonitorafter your own interceptors are registered on the instancemodule scope
attachFetchMonitorbefore the first fetch you want captured — client-side onlymodule scope
createReduxMonitorMiddlewarein configureStore's middleware callbackmodule scope
tapRealtimeAdapterwhere the adapter singleton is constructedmodule scope
tapQueryClienta useEffect in your query providersee below
captureEncryptedinside your own encrypt/decrypt functions, on success paths onlyper request — optional, see below

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.

typescriptts
// src/network/axios.ts
import 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:

typescriptts
// 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:

typescriptts
// Fine since 0.11: the failure was recorded before this ran
instance.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 timeout that 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 no timeout that never answers stays pending: 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.config alone.

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 the auth option. When auth is set, the panel keeps the Authorization it 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-TOKEN by default).
  • Headers the browser manages itself — Cookie, Origin, User-Agent and 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:

typescriptts
// src/network/axios.ts
import { 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:

typescriptts
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:

typescriptts
// 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:

StampVisibilitySurvives { ...config }
a registry-global Symbol, non-enumerableinvisible to Object.keys, JSON.stringify, Object.entriesno — spread copies only enumerable own properties
__monitorId, a plain enumerable string propertyshows up in Object.keys and a JSON.stringify of the configyes

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 with NEXT_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 attachHttpMonitor was 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 payload is missing, or both request and response on it are null/undefined (a bare { request } will not blank a response captured 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:

typescriptts
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:

typescriptts
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 use

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

typescriptts
// instrumentation-client.ts (Next.js 15.3+), or the first module your client bundle evaluates
import { 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:

SituationWhat is recorded
Content-Type: text/event-streamstatus, headers, timing — body not captured, because the stream does not end
Content-Length over the capstatus, headers, timing — body not captured, reading never starts
body passes the cap while downloadingstatus, headers, timing — body not captured, reading abandoned
no-cors (opaque) responsestatus 0 — headers and body not captured, because the browser hides them
request body is a ReadableStreamrequest body not captured — reading it would consume the upload

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:

typescriptts
attachFetchMonitor({ maxBodyBytes: 1024 * 1024 }); // 1 MB — 0 captures no bodies

The 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:

RuleMatches
"/_next/"Next.js assets, HMR updates and Pages Router data requests
"/__nextjs"the Next.js dev overlay — stack frames, source maps, open-in-editor
/[?&]_rsc=/App Router navigations and <Link> prefetches
".hot-update."webpack HMR update manifests and chunks
"/__webpack_hmr"webpack-hot-middleware

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:

typescriptts
attachFetchMonitor({ ignore: (defaults) => [...defaults, "/api/health"] }); // extend
attachFetchMonitor({ ignore: [] }); // capture everything

An 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, mode or cache, 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 fetch that never answers stays pending until the page aborts it: a fetch has no timeout of its own for Blix to measure against. (An AbortSignal.timeout() that fires is recorded, as a timeout.) axios differs: a request with a timeout is 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:

Field
kindHow it failed — see below. Always present.
messageThe error's message. Always present.
name, codeThe error's class (AxiosError, TypeError) and the client's code (ERR_NETWORK, ECONNABORTED).
status, statusTextWhen the server answered.
responseThe response body, when there was one.
timeoutMsThe timeout the request ran under, when that is what ended it.
causeWhat the error says caused it — the same fields, without kind.
stackThe top frames of the error's stack.
handledAsWhat your interceptor rejected with instead, when it replaced the client's error — the same fields, without kind.
kindMeaning
httpThe server answered with a status the client treats as an error.
timeoutNo answer inside the configured time.
networkNo answer at all: offline, DNS, refused, or blocked by CORS — the browser does not tell scripts which.
abortedCancelled by the page: AbortController, a cancel token, navigation.
setupThrown before the request left — by an interceptor, or while building it.
unknownNone of the above could be established.

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:

Field
totalMsThe browser's total for the request. Always present.
queueMs, dnsMs, connectMs, tlsMsWaiting for a connection, DNS, TCP, TLS.
waitMs, downloadMsRequest sent to first byte (TTFB), and first byte to last.
redirectMsTime spent in redirects, when there were any.
transferSize, encodedBodySize, decodedBodySizeBytes over the wire, the body as sent, and the body decoded.
fromCacheNothing crossed the wire, yet there was a body.
protocolh2, http/1.1, …
serverTimingThe response's Server-Timing metrics: name, durationMs, description.
restrictedSee below.

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?)

typescriptts
// src/store.ts
import { 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", …).

typescriptts
// src/realtime/adapter.ts
import { 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 RealtimeAdapterLike pass straight through — the proxy's fallback binds and forwards any property that is not one of the tapped methods;
  • instanceof still works against your concrete class, because the proxy has no getPrototypeOf trap and forwards to the target.
typescriptts
// 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:

tsxtsx
"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.

Edit this page on GitHubLast updated October 16, 2026
Docs