Skip to content

Instantly share code, notes, and snippets.

@jonashw
Last active June 8, 2026 22:18
Show Gist options
  • Select an option

  • Save jonashw/df8c914c706a50ce1a73e86740bd210c to your computer and use it in GitHub Desktop.

Select an option

Save jonashw/df8c914c706a50ce1a73e86740bd210c to your computer and use it in GitHub Desktop.

ADR: NetworkObserver Injection Method

Context

We need to inject NetworkObserver into the browser before any page scripts execute so that all fetch/XHR traffic is captured from page load.

Decision

Tampermonkey userscript for immediate use; custom Chrome extension as a future option if more control is needed.

Alternatives

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

Rationale

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.

Interface

/** 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;

Design Summary

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

Usage Examples

Callback style

// 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();

Stream style

// 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();
@jonashw

jonashw commented Jun 8, 2026

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment