We need to inject NetworkObserver into the browser before any page scripts execute so that all fetch/XHR traffic is captured from page load.
Tampermonkey userscript for immediate use; custom Chrome extension as a future option if more control is needed.
| Criteria | Tampermonkey Userscript | Custom Chrome Extension | Playwright addInitScript |
|---|---|---|---|
| Time to market | Minutes — no build step, paste and go | Hours — requires manifest, packaging, sideloading | Minutes — one API call |
| Captures 100% of fetch/XHR | Yes (@run-at document-start, @grant none) |
Yes ("run_at": "document_start", "world": "MAIN") |
Yes (runs before any page script) |
| Per-site targeting | @match / @include directives |
matches in manifest content_scripts |
Per-context/page in test code |
| Toggle on/off | Built-in via Tampermonkey dashboard | Must disable extension or add custom toggle UI | Controlled in script logic |
| Distribution | Share as .user.js file |
Requires CRX packaging or Chrome Web Store | Part of test/automation codebase |
Access to chrome.* APIs |
No | Yes (webRequest, storage, declarativeNetRequest) | No (page-world JS only) |
| Isolated context support | No — must run in page world | Yes — can run in both isolated and MAIN worlds | No — page world only |
| Long-term maintainability | Acceptable for single-file scripts | Better for multi-file, versioned codebases | Good — lives in version-controlled automation |
| Dependency on third-party extension | Yes (Tampermonkey must be installed) | No | Yes (Playwright runtime required) |
| Supports manual browsing | Yes | Yes | No — automated sessions only |
Tampermonkey satisfies all current requirements (global intercept, read-only observation, replay buffer) with near-zero setup. A custom extension becomes worthwhile only if we later need chrome.* APIs (e.g., webRequest for non-fetch traffic, storage for persistence, or managed distribution to a team). Playwright was considered but rejected because the primary use case is capturing traffic during manual browsing sessions — Playwright requires programmatic control of the browser and cannot inject into an interactive user session.
/** Configuration */
interface NetworkObserverOptions {
/** How long (ms) to retain events for replay to late subscribers. Default: 300000 (5 min) */
replayWindow?: number;
}
/** Correlation ID assigned to each request lifecycle */
type RequestId = string;
/** Emitted when a fetch/XHR is initiated (before network) */
interface RequestEvent {
requestId: RequestId;
url: string;
method: string;
headers: Record<string, string>;
body: unknown | null;
startTime: number; // performance.now()
}
/** Emitted when a response is received */
interface ResponseEvent {
requestId: RequestId;
url: string;
method: string;
status: number;
requestHeaders: Record<string, string>;
responseHeaders: Record<string, string>;
body: unknown | null;
startTime: number;
endTime: number;
duration: number; // endTime - startTime
responseSize: number | null; // bytes, from Content-Length or body
}
/** Emitted when a request fails (network error, abort, timeout) */
interface ErrorEvent {
requestId: RequestId;
url: string;
method: string;
error: Error;
startTime: number;
endTime: number;
duration: number;
}
/** Unsubscribe function returned by on.request/response/error() */
type Unsubscribe = () => void;
/** Fluent stream — all operators return a new Stream with the same interface */
interface Stream<T> {
subscribe(next?: (value: T) => void): Subscription;
filter(predicate: (value: T) => boolean): Stream<T>;
map<R>(project: (value: T) => R): Stream<R>;
tap(fn: (value: T) => void): Stream<T>;
scan<R>(accumulator: (acc: R, value: T) => R, seed: R): Stream<R>;
debounceTime(ms: number): Stream<T>;
throttleTime(ms: number): Stream<T>;
take(count: number): Stream<T>;
takeUntil(signal: Stream<any>): Stream<T>;
}
interface Subscription {
unsubscribe(): void;
}
/** Tagged union for the merged stream */
type NetworkEvent =
| { type: 'request' } & RequestEvent
| { type: 'response' } & ResponseEvent
| { type: 'error' } & ErrorEvent;
/** Primary entry point: window.NetworkObserver */
interface NetworkObserver {
/** Callback-style subscriptions — each returns an unsubscribe function */
on: {
request(cb: (event: RequestEvent) => void): Unsubscribe;
response(cb: (event: ResponseEvent) => void): Unsubscribe;
error(cb: (event: ErrorEvent) => void): Unsubscribe;
};
/** Fluent Observable streams for declarative pipelines */
stream: {
all: Stream<NetworkEvent>;
request: Stream<RequestEvent>;
response: Stream<ResponseEvent>;
error: Stream<ErrorEvent>;
};
/** Remove all listeners and restore original fetch/XHR */
destroy(): void;
}
declare var NetworkObserver: NetworkObserver;| Aspect | Choice |
|---|---|
| Environment | Browser only (monkey-patches fetch and XMLHttpRequest) |
| Scope | Global intercept |
| Mutation | Read-only — observers cannot modify requests |
| Entry point | window.NetworkObserver |
| Callback API | NetworkObserver.on.request(cb) → unsub() |
| Stream API | NetworkObserver.stream.response.filter(...).map(...).subscribe(cb) |
| Stream style | Fluent/chainable — every operator returns a new Stream with the same methods |
| Event types | request, response, error (separate) + merged all stream |
| Filtering | Fluent .filter() on streams, or manual in callbacks |
| Replay | Time-window buffer (default 5 min); late subscribers get buffered events |
| Teardown | destroy() restores originals and clears all listeners |
| Context | Main page + same-origin iframes |
| Observable dependency | None — hand-rolled minimal contract |
| Multicast | Yes — all subscribers share the same underlying listener per type |
| Stream completion | Never completes; use .take(n) / .takeUntil(signal) for bounded subscriptions |
// Simple — log all responses
const unsub = NetworkObserver.on.response((e) => {
console.log(e.method, e.url, e.status, Math.round(e.duration) + 'ms');
});
// Later: unsub();// First 5 API responses to a specific host
NetworkObserver.stream.response
.filter(e => e.host.includes('forgerock.io'))
.map(e => ({ path: e.path, status: e.status, ms: Math.round(e.duration) }))
.take(5)
.subscribe(e => console.table([e]));
// Accumulate stats, debounced
NetworkObserver.stream.response
.scan((acc, e) => ({ n: acc.n + 1, ms: acc.ms + e.duration }), { n: 0, ms: 0 })
.debounceTime(2000)
.map(s => ({ count: s.n, avgMs: Math.round(s.ms / s.n) }))
.subscribe(s => console.log(`${s.count} responses, avg ${s.avgMs}ms`));
// Auth-bearing requests
NetworkObserver.stream.request
.filter(e => 'authorization' in e.headers || 'Authorization' in e.headers)
.tap(e => console.log('AUTH →', e.method, e.path))
.subscribe();
Implementation: https://gist.github.com/jonashw/d314c2b1dd0e08948bb588245344a4ef