Repository: amplitude/Amplitude-TypeScript
Pull request #1080: feat(analytics-browser): observe XHR along with "fetch"
Author: login: daniel-graham-amplitude
Created: 2025-05-12T23:29:23Z | Merged: 2025-05-16T18:33:52Z

<!---
Thanks for contributing to the Amplitude TypeScript repository! 🎉

Please fill out the following sections to help us quickly review your pull request.
--->

### Summary
* Adding support to `XMLHttpRequest` so we get full coverage of both "fetch" and "XMLHttpRequest"
* Allow setting `config.autocapture.networkTracking` as a NetworkTrackingOptions object. Deprecate `config.networkTrackingOptions` in favour of that
* Also, added some more safety checks and tests that ensure the code is safe now and safe from being meddled with in the future

## Comments
* Added tons of type safety. It may seem unreasonable, but considering the risks of overriding "fetch" and "XMLHttpRequest" it's warranted. Both to prevent defects/misuse now, and to prevent future maintainers from shooting themselves in the foot
* Added tests to ensure type safety
* The "fetch.html" and "xhr.html" files are there as tests to run in the browser

### Checklist

* [ ] Does your PR title have the correct [title format](https://github.com/amplitude/Amplitude-TypeScript/blob/main/CONTRIBUTING.md#pr-commit-title-conventions)?
* Does your PR have a breaking change?:  <!-- Yes or no -->



Diff:
diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts
index c101f3163..7b412aea2 100644
--- a/packages/analytics-browser/src/browser-client.ts
+++ b/packages/analytics-browser/src/browser-client.ts
@@ -157,9 +157,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
       await this.add(autocapturePlugin(getElementInteractionsConfig(this.config))).promise;
     }
 
-    // TODO: the "this.config.networkTrackingOptions" should be taken out once we have
-    // beta tested network tracking
-    if (isNetworkTrackingEnabled(this.config.autocapture) && !!this.config.networkTrackingOptions) {
+    if (isNetworkTrackingEnabled(this.config.autocapture)) {
       this.config.loggerProvider.debug('Adding network tracking plugin');
       await this.add(networkCapturePlugin(getNetworkTrackingConfig(this.config))).promise;
     }
diff --git a/packages/analytics-browser/src/default-tracking.ts b/packages/analytics-browser/src/default-tracking.ts
index c96892989..13737b65a 100644
--- a/packages/analytics-browser/src/default-tracking.ts
+++ b/packages/analytics-browser/src/default-tracking.ts
@@ -96,7 +96,10 @@ export const getElementInteractionsConfig = (config: BrowserOptions): ElementInt
 };
 
 export const getNetworkTrackingConfig = (config: BrowserOptions): NetworkTrackingOptions | undefined => {
-  if (isNetworkTrackingEnabled(config.autocapture) && config.networkTrackingOptions) {
+  if (isNetworkTrackingEnabled(config.autocapture)) {
+    if (typeof config.autocapture === 'object' && typeof config.autocapture.networkTracking === 'object') {
+      return config.autocapture.networkTracking;
+    }
     return config.networkTrackingOptions;
   }
   return;
diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts
index 445c8703b..57df70903 100644
--- a/packages/analytics-browser/test/browser-client.test.ts
+++ b/packages/analytics-browser/test/browser-client.test.ts
@@ -403,8 +403,9 @@ describe('browser-client', () => {
     test('should use network tracking plugin when autocapture is on', async () => {
       const networkTrackingPlugin = jest.spyOn(networkCapturePlugin, 'plugin');
       await client.init(apiKey, userId, {
-        autocapture: true,
-        networkTrackingOptions: {}, // TODO: may not need this in the future?
+        autocapture: {
+          networkTracking: true,
+        },
       }).promise;
       expect(networkTrackingPlugin).toHaveBeenCalledTimes(1);
     });
diff --git a/packages/analytics-browser/test/default-tracking.test.ts b/packages/analytics-browser/test/default-tracking.test.ts
index 1848b6b50..4627b2d66 100644
--- a/packages/analytics-browser/test/default-tracking.test.ts
+++ b/packages/analytics-browser/test/default-tracking.test.ts
@@ -295,6 +295,32 @@ describe('getAttributionTrackingConfig', () => {
 });
 
 describe('getNetworkTrackingConfig', () => {
+  test('should return object when autocapture.networkTracking is set', () => {
+    const config = getNetworkTrackingConfig({
+      autocapture: {
+        networkTracking: {
+          ignoreAmplitudeRequests: true,
+          ignoreHosts: ['example.com'],
+          captureRules: [
+            {
+              hosts: ['example.com'],
+              statusCodeRange: '500-599',
+            },
+          ],
+        },
+      },
+    });
+
+    expect(config?.ignoreAmplitudeRequests).toBe(true);
+    expect(config?.ignoreHosts).toEqual(['example.com']);
+    expect(config?.captureRules).toEqual([
+      {
+        hosts: ['example.com'],
+        statusCodeRange: '500-599',
+      },
+    ]);
+  });
+
   test('should return undefined when networkTracking is not defined', () => {
     const config = getNetworkTrackingConfig({
       networkTrackingOptions: undefined,
diff --git a/packages/analytics-core/src/network-observer.ts b/packages/analytics-core/src/network-observer.ts
index 0058d45fa..ca2402fe2 100644
--- a/packages/analytics-core/src/network-observer.ts
+++ b/packages/analytics-core/src/network-observer.ts
@@ -1,7 +1,27 @@
 import { getGlobalScope } from './';
 import { UUID } from './utils/uuid';
 import { ILogger } from '.';
-import { NetworkRequestEvent, RequestWrapper, ResponseWrapper } from './network-request-event';
+import {
+  IRequestWrapper,
+  NetworkRequestEvent,
+  RequestWrapperFetch,
+  ResponseWrapperFetch,
+  RequestWrapperXhr,
+  ResponseWrapperXhr,
+  IResponseWrapper,
+  RequestInitSafe,
+  XMLHttpRequestBodyInitSafe,
+} from './network-request-event';
+
+// object that is added to each XHR instance so
+// that info can be set in xhr.open and retrieved in xhr.send
+type AmplitudeAnalyticsEvent = {
+  method: string;
+  url: string | URL;
+  startTime: number;
+  durationStart: number;
+  status?: number;
+};
 
 /**
  * Typeguard function checks if an input is a Request object.
@@ -16,6 +36,23 @@ export class NetworkEventCallback {
   constructor(public readonly callback: (event: NetworkRequestEvent) => void, public readonly id: string = UUID()) {}
 }
 
+type RequestUrlAndMethod = {
+  url: string | URL | undefined;
+  method: string | undefined;
+};
+
+// A narrowed down [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) type
+// that only includes the properties we need to access and adds the $$AmplitudeAnalyticsEvent property
+// Use great care when modifying this type, make sure you only use read-only properties and only add
+// what you need to access, nothing more.
+type AmplitudeXMLHttpRequestSafe = {
+  $$AmplitudeAnalyticsEvent: AmplitudeAnalyticsEvent;
+  status: number;
+  getAllResponseHeaders: typeof XMLHttpRequest.prototype.getAllResponseHeaders;
+  getResponseHeader: typeof XMLHttpRequest.prototype.getResponseHeader;
+  addEventListener: (type: 'loadend', listener: () => void) => void;
+};
+
 export class NetworkObserver {
   private eventCallbacks: Map<string, NetworkEventCallback> = new Map();
   // eslint-disable-next-line no-restricted-globals
@@ -43,15 +80,25 @@ export class NetworkObserver {
     }
     this.eventCallbacks.set(eventCallback.id, eventCallback);
     if (!this.isObserving) {
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrOpen = this.globalScope?.XMLHttpRequest?.prototype?.open;
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrSend = this.globalScope?.XMLHttpRequest?.prototype?.send;
+      if (originalXhrOpen && originalXhrSend) {
+        this.observeXhr(originalXhrOpen, originalXhrSend);
+      }
+
       /* istanbul ignore next */
       const originalFetch = this.globalScope?.fetch;
       /* istanbul ignore next */
-      if (!originalFetch) {
-        return;
+      if (originalFetch) {
+        this.observeFetch(originalFetch);
       }
+
       /* istanbul ignore next */
       this.isObserving = true;
-      this.observeFetch(originalFetch);
     }
   }
 
@@ -73,9 +120,10 @@ export class NetworkObserver {
   }
 
   private handleNetworkRequestEvent(
-    requestInfo: RequestInfo | URL | undefined,
-    requestWrapper: RequestWrapper | undefined,
-    responseWrapper: ResponseWrapper | undefined,
+    requestType: 'fetch' | 'xhr',
+    requestInfo: RequestInfo | URL | RequestUrlAndMethod | undefined,
+    requestWrapper: IRequestWrapper | undefined,
+    responseWrapper: IResponseWrapper | undefined,
     typedError: Error | undefined,
     startTime?: number,
     durationStart?: number,
@@ -115,7 +163,7 @@ export class NetworkObserver {
     const endTime = Math.floor(startTime + duration);
 
     const requestEvent = new NetworkRequestEvent(
-      'fetch',
+      requestType,
       method,
       startTime, // timestamp and startTime are aliases
       startTime,
@@ -131,7 +179,7 @@ export class NetworkObserver {
     this.triggerEventCallbacks(requestEvent);
   }
 
-  getTimestamps() {
+  private getTimestamps() {
     /* istanbul ignore next */
     return {
       startTime: Date.now?.(),
@@ -174,9 +222,10 @@ export class NetworkObserver {
       // 3. call the handler after the fetch call is done
       try {
         this.handleNetworkRequestEvent(
+          'fetch',
           requestInfo,
-          requestInit ? new RequestWrapper(requestInit) : undefined,
-          originalResponse ? new ResponseWrapper(originalResponse) : undefined,
+          requestInit ? new RequestWrapperFetch(requestInit as RequestInitSafe) : undefined,
+          originalResponse ? new ResponseWrapperFetch(originalResponse) : undefined,
           originalError as Error,
           /* istanbul ignore next */
           timestamps?.startTime,
@@ -199,6 +248,96 @@ export class NetworkObserver {
       }
     };
   }
+
+  private observeXhr(
+    originalXhrOpen:
+      | ((
+          method: string,
+          url: string | URL,
+          async?: boolean,
+          username?: string | null,
+          password?: string | null,
+        ) => void)
+      | undefined,
+    originalXhrSend: ((body?: Document | XMLHttpRequestBodyInit | null) => void) | undefined,
+  ) {
+    /* istanbul ignore next */
+    if (!this.globalScope || !originalXhrOpen || !originalXhrSend) {
+      return;
+    }
+
+    const xhrProto = this.globalScope.XMLHttpRequest.prototype;
+
+    const networkObserverContext = this as NetworkObserver;
+
+    /**
+     * IMPORTANT: This overrides window.XMLHttpRequest.prototype.open
+     * You probably never need to make changes to this function.
+     * If you do, please be careful to preserve the original functionality of xhr.open
+     * and make sure another developer who is an expert reviews this change throughly
+     */
+    xhrProto.open = function (...args: any[]) {
+      const xhrSafe = this as unknown as AmplitudeXMLHttpRequestSafe;
+      const [method, url] = args as [string, string | URL];
+      try {
+        /* istanbul ignore next */
+        xhrSafe.$$AmplitudeAnalyticsEvent = {
+          method,
+          url: url?.toString?.(),
+          ...networkObserverContext.getTimestamps(),
+        } as AmplitudeAnalyticsEvent;
+      } catch (err) {
+        /* istanbul ignore next */
+        networkObserverContext.logger?.debug('an unexpected error occurred while calling xhr open', err);
+      }
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+      return originalXhrOpen.apply(xhrSafe, args as any);
+    };
+
+    /**
+     * IMPORTANT: This overrides window.XMLHttpRequest.prototype.send
+     * You probably never need to make changes to this function.
+     * If you do, please be careful to preserve the original functionality of xhr.send
+     * and make sure another developer who is an expert reviews this change throughly
+     */
+    // allow "any" type for args to reflect how it's used in the browser
+    /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */
+    xhrProto.send = function (...args: any[]) {
+      // eslint-disable-next-line @typescript-eslint/no-this-alias
+      const xhrSafe = this as unknown as AmplitudeXMLHttpRequestSafe;
+      const body = args[0] as XMLHttpRequestBodyInitSafe;
+      const requestEvent = xhrSafe.$$AmplitudeAnalyticsEvent;
+
+      xhrSafe.addEventListener('loadend', function () {
+        try {
+          const responseHeaders = xhrSafe.getAllResponseHeaders();
+          const responseBodySize = xhrSafe.getResponseHeader('content-length');
+          const responseWrapper = new ResponseWrapperXhr(
+            xhrSafe.status,
+            responseHeaders,
+            /* istanbul ignore next */
+            responseBodySize ? parseInt(responseBodySize, 10) : undefined,
+          );
+          const requestWrapper = new RequestWrapperXhr(body);
+          requestEvent.status = xhrSafe.status;
+          networkObserverContext.handleNetworkRequestEvent(
+            'xhr',
+            { url: requestEvent.url, method: requestEvent.method },
+            requestWrapper,
+            responseWrapper,
+            undefined,
+            requestEvent.startTime,
+            requestEvent.durationStart,
+          );
+        } catch (err) {
+          /* istanbul ignore next */
+          networkObserverContext.logger?.debug('an unexpected error occurred while handling xhr send', err);
+        }
+      });
+      /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */
+      return originalXhrSend.apply(xhrSafe, args as any);
+    };
+  }
 }
 
 // singleton instance of NetworkObserver
diff --git a/packages/analytics-core/src/network-request-event.ts b/packages/analytics-core/src/network-request-event.ts
index a2cf5fd8c..a53a63add 100644
--- a/packages/analytics-core/src/network-request-event.ts
+++ b/packages/analytics-core/src/network-request-event.ts
@@ -1,60 +1,131 @@
 import { getGlobalScope } from './global-scope';
 
-// adding types to make this compile in NodeJS
-type FormDataEntryValueBrowser = string | Blob | null;
-export interface FormDataBrowser {
-  entries(): IterableIterator<[string, FormDataEntryValueBrowser]>;
+/* SAFE TYPE DEFINITIONS
+  These type definitions expose limited properties of the original types
+  to prevent the consumer from mutating them or consuming them.
+*/
+type BlobSafe = {
+  size: number;
+};
+
+type ArrayBufferSafe = {
+  byteLength: number;
+};
+
+type ArrayBufferViewSafe = {
+  byteLength: number;
+};
+
+type URLSearchParamsSafe = {
+  toString(): string;
+};
+
+// no method on readablestream is safe to call
+type ReadableStreamSafe = Record<string, never>;
+
+type FormDataEntryValueSafe = string | BlobSafe | null;
+
+type BodyInitSafe =
+  | string
+  | Blob
+  | ArrayBufferSafe
+  | FormDataSafe
+  | URLSearchParamsSafe
+  | ArrayBufferViewSafe
+  | null
+  | undefined;
+
+type HeadersRequestSafe = {
+  entries(): IterableIterator<[string, string]>;
+  forEach(callbackfn: (value: string, key: string) => void): void;
+};
+
+type HeadersResponseSafe = {
+  get(name: string): string | null;
+  forEach(callbackfn: (value: string, key: string) => void): void;
+};
+
+type HeadersInitSafe = HeadersRequestSafe | Record<string, string> | string[][];
+
+type ResponseSafe = {
+  status: number;
+  headers: HeadersResponseSafe | undefined;
+};
+
+export type RequestInitSafe = {
+  method?: string;
+  headers?: HeadersInitSafe;
+  body?: BodyInitSafe;
+};
+export interface FormDataSafe {
+  entries(): IterableIterator<[string, FormDataEntryValueSafe]>;
 }
+export type XMLHttpRequestBodyInitSafe = BlobSafe | FormDataSafe | URLSearchParamsSafe | string;
 
 export type FetchRequestBody =
   | string
-  | Blob
-  | ReadableStream
-  | ArrayBuffer
-  | FormDataBrowser
-  | URLSearchParams
+  | BlobSafe
+  | ArrayBufferSafe
+  | FormDataSafe
+  | URLSearchParamsSafe
+  | ArrayBufferViewSafe
   | null
   | undefined;
 
+export interface IRequestWrapper {
+  headers?: Record<string, string>;
+  bodySize?: number;
+  method?: string;
+  body?: FetchRequestBody | XMLHttpRequestBodyInitSafe | null;
+}
+
+export const MAXIMUM_ENTRIES = 100;
+
 /**
- * This class encapsulates the Request object so that the consumer can
- * only get access to the headers, method and body size.
+ * This class encapsulates the RequestInit (https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)
+ * object so that the consumer can only get access to the headers, method and body size.
  *
  * This is to prevent consumers from directly accessing the Request object
  * and mutating it or running costly operations on it.
  *
- * IMPORTANT: Do not make changes to this class without careful consideration
- * of performance implications, memory usage and potential to mutate the customer's
- * request.
+ * IMPORTANT:
+ *    * Do not make changes to this class without careful consideration
+ *      of performance implications, memory usage and potential to mutate the customer's
+ *      request.
+ *   * NEVER .clone() the RequestInit object. This will 2x's the memory overhead of the request
+ *   * NEVER: call .arrayBuffer(), text(), json() or any other method on the body that
+ *     consumes the body's stream. This will cause the response to be consumed
+ *     meaning the body will be empty when the customer tries to access it.
+ *     (ie: if the body is an instanceof https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
+ *      never call any of the methods on it)
  */
-export class RequestWrapper {
-  private MAXIMUM_ENTRIES = 100;
+export class RequestWrapperFetch implements IRequestWrapper {
   private _headers: Record<string, string> | undefined;
   private _bodySize: number | undefined;
-  constructor(private request: RequestInit) {}
+  constructor(private request: RequestInitSafe) {}
 
-  get headers(): Record<string, string> {
+  get headers(): Record<string, string> | undefined {
     if (this._headers) return this._headers;
 
-    if (Array.isArray(this.request.headers)) {
-      this._headers = this.request.headers.reduce((acc, [key, value]) => {
+    const headersUnsafe = this.request.headers;
+    if (Array.isArray(headersUnsafe)) {
+      const headers = headersUnsafe;
+      this._headers = headers.reduce((acc, [key, value]) => {
         acc[key] = value;
         return acc;
       }, {} as Record<string, string>);
-      return this._headers;
+    } else if (headersUnsafe instanceof Headers) {
+      const headersSafe = headersUnsafe as HeadersRequestSafe;
+      const headersObj: Record<string, string> = {};
+      headersSafe.forEach((value, key) => {
+        headersObj[key] = value;
+      });
+      this._headers = headersObj;
+    } else if (typeof headersUnsafe === 'object') {
+      this._headers = headersUnsafe as Record<string, string>;
     }
 
-    if (!(this.request.headers instanceof Headers)) {
-      this._headers = { ...this.request.headers } as Record<string, string>;
-      return this._headers;
-    }
-
-    const headers: Record<string, string> = {};
-    this.request.headers.forEach((value, key) => {
-      headers[key] = value;
-    });
-    this._headers = headers;
-    return headers;
+    return this._headers;
   }
 
   get bodySize(): number | undefined {
@@ -65,49 +136,9 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
@@ -115,8 +146,84 @@ export class RequestWrapper {
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: XMLHttpRequestBodyInitSafe | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(bodyUnsafe: FetchRequestBody, maxEntries: number): number | undefined {
+  let bodySize: number | undefined;
+  const global = getGlobalScope();
+  /* istanbul ignore next */
+  const TextEncoder = global?.TextEncoder;
+  /* istanbul ignore next */
+  if (!TextEncoder) {
+    return;
+  }
+  let bodySafe;
+  if (typeof bodyUnsafe === 'string') {
+    bodySafe = bodyUnsafe;
+    bodySize = new TextEncoder().encode(bodySafe).length;
+  } else if (bodyUnsafe instanceof Blob) {
+    bodySafe = bodyUnsafe as BlobSafe;
+    bodySize = bodySafe.size;
+  } else if (bodyUnsafe instanceof URLSearchParams) {
+    bodySafe = bodyUnsafe as URLSearchParamsSafe;
+    bodySize = new TextEncoder().encode(bodySafe.toString()).length;
+  } else if (ArrayBuffer.isView(bodyUnsafe)) {
+    bodySafe = bodyUnsafe as ArrayBufferViewSafe;
+    bodySize = bodySafe.byteLength;
+  } else if (bodyUnsafe instanceof ArrayBuffer) {
+    bodySafe = bodyUnsafe as ArrayBufferSafe;
+    bodySize = bodySafe.byteLength;
+  } else if (bodyUnsafe instanceof FormData) {
+    // Estimating only for text parts; not accurate for files
+    const formData = bodyUnsafe as unknown as FormDataSafe;
+
+    let total = 0;
+    let count = 0;
+    for (const [key, value] of formData.entries()) {
+      total += key.length;
+      if (typeof value === 'string') {
+        total += new TextEncoder().encode(value).length;
+      } else if (value instanceof Blob) {
+        total += value.size;
+      } else {
+        // encountered an unknown type
+        // we can't estimate the size of this entry
+        return;
+      }
+      // terminate if we reach the maximum number of entries
+      // to avoid performance issues in case of very large FormData
+      if (++count >= maxEntries) {
+        return;
+      }
+    }
+    bodySize = total;
+  } else if (bodyUnsafe instanceof ReadableStream) {
+    // If bodyUnsafe is an instanceof ReadableStream, we can't determine the size,
+    // without consuming it, so we return undefined.
+    // Never ever consume ReadableStream! DO NOT DO IT!!!
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    bodySafe = bodyUnsafe as unknown as ReadableStreamSafe;
+    return;
+  }
+  return bodySize;
+}
+
+export interface IResponseWrapper {
+  headers?: Record<string, string>;
+  bodySize?: number;
+  status?: number;
+  body?: string | Blob | ReadableStream | ArrayBuffer | FormDataSafe | URLSearchParams | ArrayBufferView | null;
+}
+
 /**
- * This class encapsulates the Response object so that the consumer can
+ * This class encapsulates the Fetch API Response object
+ * (https://developer.mozilla.org/en-US/docs/Web/API/Response) so that the consumer can
  * only get access to the headers and body size.
  *
  * This is to prevent consumers from directly accessing the Response object
@@ -126,24 +233,32 @@ export class RequestWrapper {
  *   * Do not make changes to this class without careful consideration
  *     of performance implications, memory usage and potential to mutate the customer's
  *     response.
- *   * NEVER .clone the response object. This 2x's the memory overhead of the response
+ *   * NEVER .clone() the Response object. This will 2x's the memory overhead of the response
  *   * NEVER consume the body's stream. This will cause the response to be consumed
  *     meaning the body will be empty when the customer tries to access it.
+ *     (ie: if the body is an instanceof https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
+ *      never call any of the methods on it)
  */
-export class ResponseWrapper {
+export class ResponseWrapperFetch implements IResponseWrapper {
   private _headers: Record<string, string> | undefined;
   private _bodySize: number | undefined;
-  constructor(private response: Response) {}
+  constructor(private response: ResponseSafe) {}
 
-  get headers(): Record<string, string> {
+  get headers(): Record<string, string> | undefined {
     if (this._headers) return this._headers;
-    const headers: Record<string, string> = {};
-    /* istanbul ignore next */
-    this.response.headers?.forEach?.((value, key) => {
-      headers[key] = value;
-    });
-    this._headers = headers;
-    return headers;
+
+    if (this.response.headers instanceof Headers) {
+      const headersSafe = this.response.headers as HeadersResponseSafe;
+      const headersOut: Record<string, string> = {};
+      /* istanbul ignore next */
+      headersSafe?.forEach?.((value, key) => {
+        headersOut[key] = value;
+      });
+      this._headers = headersOut;
+      return headersOut;
+    }
+
+    return;
   }
 
   get bodySize(): number | undefined {
@@ -160,17 +275,44 @@ export class ResponseWrapper {
   }
 }
 
+export class ResponseWrapperXhr implements IResponseWrapper {
+  constructor(readonly statusCode: number, readonly headersString: string, readonly size: number | undefined) {}
+
+  get bodySize(): number | undefined {
+    return this.size;
+  }
+
+  get status(): number {
+    return this.statusCode;
+  }
+
+  get headers(): Record<string, string> | undefined {
+    if (!this.headersString) {
+      return;
+    }
+    const headers: Record<string, string> = {};
+    const headerLines = this.headersString.split('\r\n');
+    for (const line of headerLines) {
+      const [key, value] = line.split(': ');
+      if (key && value) {
+        headers[key] = value;
+      }
+    }
+    return headers;
+  }
+}
+
 export class NetworkRequestEvent {
   constructor(
-    public readonly type: string,
+    public readonly type: 'xhr' | 'fetch',
     public readonly method: string,
     public readonly timestamp: number,
     public readonly startTime: number,
     public readonly url?: string,
-    public readonly requestWrapper?: RequestWrapper,
+    public readonly requestWrapper?: IRequestWrapper,
     public readonly status?: number,
     public readonly duration?: number,
-    public readonly responseWrapper?: ResponseWrapper,
+    public readonly responseWrapper?: IResponseWrapper,
     public readonly error?: {
       name: string;
       message: string;
diff --git a/packages/analytics-core/src/types/browser-config.ts b/packages/analytics-core/src/types/browser-config.ts
index c8b520241..eb86b2dcc 100644
--- a/packages/analytics-core/src/types/browser-config.ts
+++ b/packages/analytics-core/src/types/browser-config.ts
@@ -84,6 +84,7 @@ export interface ExternalBrowserConfig extends IConfig {
   fetchRemoteConfig?: boolean;
   /**
    * Captures network requests and responses.
+   * @deprecated This property is deprecated. Use `autocapture.networkTracking` instead.
    * @defaultValue `undefined`
    */
   networkTrackingOptions?: NetworkTrackingOptions;
@@ -160,10 +161,10 @@ export interface AutocaptureOptions {
    */
   elementInteractions?: boolean | ElementInteractionsOptions;
   /**
-   * Enables/disables network request tracking.
+   * Enables/disables network request tracking or config with detailed network tracking options.
    * @defaultValue `false`
    */
-  networkTracking?: boolean;
+  networkTracking?: boolean | NetworkTrackingOptions;
 }
 
 export interface TrackingOptions {
diff --git a/packages/analytics-core/test/network-observer.test.ts b/packages/analytics-core/test/network-observer.test.ts
index e05eb0ff8..24e73a1b5 100644
--- a/packages/analytics-core/test/network-observer.test.ts
+++ b/packages/analytics-core/test/network-observer.test.ts
@@ -1,6 +1,12 @@
 import { NetworkEventCallback, NetworkRequestEvent, networkObserver } from '../src/index';
 import { NetworkObserver } from '../src/network-observer';
-import { FetchRequestBody, RequestWrapper, ResponseWrapper } from '../src/network-request-event';
+import {
+  FetchRequestBody,
+  RequestInitSafe,
+  RequestWrapperFetch,
+  ResponseWrapperFetch,
+  ResponseWrapperXhr,
+} from '../src/network-request-event';
 import * as AnalyticsCore from '../src/index';
 import { TextEncoder } from 'util';
 import * as streams from 'stream/web';
@@ -9,6 +15,9 @@ type PartialGlobal = Pick<typeof globalThis, 'fetch'>;
 
 /* eslint-disable @typescript-eslint/no-unsafe-assignment */
 /* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-empty-function */
 
 // Test subclass to access protected methods
 class TestNetworkObserver extends NetworkObserver {
@@ -165,7 +174,7 @@ describe('NetworkObserver', () => {
       });
       expect(events[0].duration).toBeGreaterThanOrEqual(0);
       expect(events[0].requestWrapper?.headers).toEqual(undefined);
-      expect(events[0].responseWrapper?.headers).toEqual({});
+      expect(events[0].responseWrapper?.headers).toEqual(undefined);
     });
 
     it('should still fetch even if eventCallback throws error', async () => {
@@ -392,45 +401,79 @@ describe('NetworkObserver', () => {
     describe('bodySize should return the body length when the body is of type', () => {
       it('string', () => {
         const body = 'Hello World!';
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBe(body.length);
         expect(requestWrapper.bodySize).toBe(body.length); // do it again to make sure it's cached
       });
       it('Blob', () => {
         const blob = new Blob(['Hello World!']);
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body: blob,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBe(blob.size);
       });
       it('ArrayBuffer', () => {
         const buffer = new ArrayBuffer(8);
-        const requestWrapper = new RequestWrapper({
+        for (let i = 0; i < buffer.byteLength; i++) {
+          (buffer as any)[i] = i;
+        }
+        const requestWrapper = new RequestWrapperFetch({
           body: buffer,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBe(buffer.byteLength);
+        expect(buffer.byteLength).toBe(8);
+
+        // check that the array buffer is not modified
+        for (let i = 0; i < buffer.byteLength; i++) {
+          expect((buffer as any)[i]).toBe(i);
+        }
       });
       it('ArrayBufferView', () => {
         const buffer = new ArrayBuffer(8);
         const arr = new Uint8Array(buffer);
-        const requestWrapper = new RequestWrapper({
+        for (let i = 0; i < arr.length; i++) {
+          arr[i] = i;
+        }
+        const requestWrapper = new RequestWrapperFetch({
           body: arr,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBe(arr.byteLength);
+
+        // check that the array buffer is not modified
+        expect(arr.byteLength).toBe(8);
+        for (let i = 0; i < arr.length; i++) {
+          expect(arr[i]).toBe(i);
+        }
       });
       it('FormData', () => {
+        // construct FormData with 2 entries
         const formData = new FormData();
         const val = 'value';
         formData.append('key', val);
         const blob = new Blob(['Hello World!']);
         formData.append('file', blob);
         const expectedSize = val.length + blob.size + 'key'.length + 'file'.length;
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body: formData as unknown as FetchRequestBody,
-        } as RequestInit);
+        } as RequestInitSafe);
+
+        // spy on all methods that are not safe to call on FormData
+        const unsafeFormDataMethods = ['append', 'delete', 'set'];
+        const spies = [];
+        for (const method of unsafeFormDataMethods) {
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+          const spy = jest.spyOn(FormData.prototype, method as any);
+          spies.push({ spy, method });
+        }
+
+        // assert bodySize is correct and that no unsafe methods were called
         expect(requestWrapper.bodySize).toBe(expectedSize);
+        expect(spies.length).toBe(unsafeFormDataMethods.length);
+        for (const { spy } of spies) {
+          expect(spy.mock.calls.length).toBe(0);
+        }
       });
       it('URLSearchParams', () => {
         const params = new URLSearchParams();
@@ -439,10 +482,23 @@ describe('NetworkObserver', () => {
         const val2 = 'value2';
         params.append('key2', val2);
         const expectedSize = 'key='.length + val.length + '&key2='.length + val2.length;
-        const requestWrapper = new RequestWrapper({
+
+        const unsafeURLSearchParamsMethods = ['append', 'delete', 'set'];
+        const spies = [];
+        for (const method of unsafeURLSearchParamsMethods) {
+          // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+          const spy = jest.spyOn(URLSearchParams.prototype, method as any);
+          spies.push({ spy, method });
+        }
+
+        const requestWrapper = new RequestWrapperFetch({
           body: params,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBe(expectedSize);
+        expect(spies.length).toBe(unsafeURLSearchParamsMethods.length);
+        for (const { spy } of spies) {
+          expect(spy.mock.calls.length).toBe(0);
+        }
       });
     });
 
@@ -462,9 +518,9 @@ describe('NetworkObserver', () => {
         };
         // eslint-disable-next-line @typescript-eslint/no-unsafe-call
         const formData = new (HackedFormData as any)();
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body: formData as unknown as FetchRequestBody,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBeUndefined();
       });
       it('FormData has >100 entries', () => {
@@ -472,36 +528,36 @@ describe('NetworkObserver', () => {
         for (let i = 0; i < 101; i++) {
           formData.append(`key${i}`, `value${i}`);
         }
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body: formData as unknown as FetchRequestBody,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBeUndefined();
       });
       it('body is undefined', () => {
         const body = undefined;
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBeUndefined();
       });
       it('globalScope is not available', () => {
         jest.spyOn(AnalyticsCore, 'getGlobalScope').mockReturnValue(undefined);
         const body = 'Hello World!';
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           body,
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.bodySize).toBeUndefined();
       });
     });
 
     describe('headers should return an object', () => {
       it('when headers is an array', () => {
-        const requestWrapper = new RequestWrapper({
+        const requestWrapper = new RequestWrapperFetch({
           headers: [
             ['Content-Type', 'application/fake'],
             ['Content-Length', '1234'],
           ],
-        } as RequestInit);
+        } as RequestInitSafe);
         expect(requestWrapper.headers).toEqual({
           'Content-Type': 'application/fake',
           'Content-Length': '1234',
@@ -510,7 +566,7 @@ describe('NetworkObserver', () => {
     });
 
     test('RequestWrapper interface changed. Make sure you know what you are doing.', () => {
-      const props = Object.getOwnPropertyNames(RequestWrapper.prototype);
+      const props = Object.getOwnPropertyNames(RequestWrapperFetch.prototype);
       const expectedProps = ['headers', 'bodySize'];
       expectedProps.forEach((prop) => {
         expect(props).toContain(prop);
@@ -538,7 +594,7 @@ describe('NetworkObserver', () => {
     };
 
     test('bodySize should return undefined if content-length is not set', () => {
-      const responseWrapper = new ResponseWrapper(mockResponse as unknown as Response);
+      const responseWrapper = new ResponseWrapperFetch(mockResponse as unknown as Response);
       const bodySize = responseWrapper.bodySize;
       expect(bodySize).toBeUndefined();
     });
@@ -548,13 +604,13 @@ describe('NetworkObserver', () => {
         ...mockResponse,
         headers: new Headers({ 'Content-Type': 'application/json', 'Content-Length': '1234' }),
       };
-      const responseWrapper = new ResponseWrapper(responseWithContentLength as unknown as Response);
+      const responseWrapper = new ResponseWrapperFetch(responseWithContentLength as unknown as Response);
       expect(responseWrapper.bodySize).toBe(1234);
       expect(responseWrapper.bodySize).toBe(1234); // 2x to check that it's cached
     });
 
     test('ResponseWrapper interface changed. Make sure you know what you are doing.', () => {
-      const props = Object.getOwnPropertyNames(ResponseWrapper.prototype);
+      const props = Object.getOwnPropertyNames(ResponseWrapperFetch.prototype);
       const expectedProps = ['headers', 'bodySize'];
       expectedProps.forEach((prop) => {
         expect(props).toContain(prop);
@@ -611,8 +667,168 @@ describe('serializeNetworkRequestEvent', () => {
   });
 });
 
+describe('observeXhr', () => {
+  interface MockXHROptions {
+    readyState?: number;
+    status?: number;
+    responseText?: string;
+  }
+
+  class MockXHR {
+    readyState = 4;
+    status = 200;
+    responseText: string = JSON.stringify({ success: true });
+    onreadystatechange: (() => void) | null = null;
+    openCalled = false;
+    sendCalled = false;
+
+    constructor(options?: MockXHROptions) {
+      if (options) {
+        if (options.readyState !== undefined) this.readyState = options.readyState;
+        if (options.status !== undefined) this.status = options.status;
+        if (options.responseText !== undefined) this.responseText = options.responseText;
+      }
+    }
+
+    getAllResponseHeaders(): string {
+      return 'Content-Type: application/json\r\nContent-Length: 1234';
+    }
+
+    getResponseHeader(header: string): string | null {
+      if (header === 'content-type') {
+        return 'application/json';
+      }
+      if (header === 'content-length') {
+        return '1234';
+      }
+      return null;
+    }
+
+    addEventListener(event: string, callback: () => void): void {
+      if (event === 'loadend') {
+        this.onreadystatechange = callback;
+      }
+    }
+
+    open(): void {
+      this.openCalled = true;
+    }
+
+    send(): void {
+      this.sendCalled = true;
+      if (typeof this.onreadystatechange === 'function') {
+        this.onreadystatechange();
+      }
+    }
+
+    setRequestHeader(header: string, value: string): void {
+      (this as any)[header] = value;
+    }
+  }
+
+  let networkObserver: any, originalGlobal;
+
+  beforeAll(() => {
+    // override globalScope to include mock XHR
+    originalGlobal = AnalyticsCore.getGlobalScope();
+    networkObserver = new NetworkObserver();
+    (networkObserver as unknown as any).globalScope = {
+      ...originalGlobal,
+      XMLHttpRequest: MockXHR,
+      TextEncoder,
+    } as any;
+  });
+
+  afterAll(() => {});
+
+  describe('calls mock XHR', () => {
+    // eslint-disable-next-line jest/no-done-callback
+    it('should call mockXHR and retrieve event and still call original open/send', (done) => {
+      const originalOpenSpy = jest.spyOn(MockXHR.prototype, 'open');
+      const originalSendSpy = jest.spyOn(MockXHR.prototype, 'send');
+      const callback = (event: NetworkRequestEvent) => {
+        try {
+          expect(originalOpenSpy).toHaveBeenCalledWith('GET', 'https://api.example.com/data');
+          expect(originalSendSpy).toHaveBeenCalledWith('hello world!');
+          expect(event.status).toBe(200);
+          expect(event.type).toBe('xhr');
+          expect(event.method).toBe('GET');
+          expect(event.url).toBe('https://api.example.com/data');
+          expect(event.responseWrapper?.headers).toEqual({
+            'Content-Type': 'application/json',
+            'Content-Length': '1234',
+          });
+          expect(event.responseWrapper?.bodySize).toBe(1234);
+          expect(event.requestWrapper?.bodySize).toBe('hello world!'.length);
+          expect(event.duration).toBeGreaterThanOrEqual(0);
+          expect(event.startTime).toBeGreaterThanOrEqual(0);
+          expect(event.endTime).toBeGreaterThanOrEqual(event.startTime);
+          expect(event.timestamp).toBeGreaterThanOrEqual(event.startTime);
+          expect(xhr.openCalled).toBe(true);
+          expect(xhr.sendCalled).toBe(true);
+          done();
+        } catch (error) {
+          done(error);
+        }
+      };
+      networkObserver.subscribe(new NetworkEventCallback(callback), undefined, true);
+      const XMLHttpRequest = (networkObserver as unknown as any).globalScope.XMLHttpRequest;
+      const xhr = new XMLHttpRequest();
+      xhr.open('GET', 'https://api.example.com/data');
+      xhr.send('hello world!');
+    });
+  });
+});
+
+describe('ResponseWrapperXhr', () => {
+  test('should return undefined if headersString is empty', () => {
+    const responseWrapper = new ResponseWrapperXhr(200, '', 0);
+    expect(responseWrapper.headers).toEqual(undefined);
+  });
+});
+
+describe('RequestWrapperFetch', () => {
+  test(
+    `ReadableStream should always return bodySize=undefined and never be consumed. ` +
+      `If this test fails, it means that the ReadableStream is being consumed ` +
+      `and needs to be fixed. No exceptions.`,
+    () => {
+      const readableStream = new streams.ReadableStream({
+        start(controller) {
+          controller.enqueue(new TextEncoder().encode('Hello World!'));
+          controller.close();
+        },
+      });
+
+      // spy on all of ReadableStream methods
+      const spies = [];
+      const methods = Object.getOwnPropertyNames(streams.ReadableStream.prototype);
+      for (const method of methods) {
+        if (typeof (readableStream as any)[method] === 'function') {
+          const spy = jest.spyOn(readableStream as any, method);
+          spies.push(spy);
+        }
+      }
+
+      const responseWrapper = new RequestWrapperFetch({
+        body: readableStream,
+        headers: new Headers({ 'Content-Type': 'application/json', 'Content-Length': '1234' }),
+        status: 200,
+      } as unknown as RequestInitSafe);
+      const bodySize = responseWrapper.bodySize;
+      expect(bodySize).toBeUndefined();
+
+      // check that no methods were called on ReadableStream
+      expect(spies.length).toBeGreaterThan(0);
+      for (const spy of spies) {
+        expect(spy).not.toHaveBeenCalled();
+      }
+    },
+  );
+});
+
 describe('networkObserver', () => {
-  test('should be an instance of NetworkObserver', () => {
+  test('singleton should be an instance of NetworkObserver', () => {
     expect(networkObserver).toBeInstanceOf(NetworkObserver);
   });
 });
diff --git a/packages/plugin-network-capture-browser/src/track-network-event.ts b/packages/plugin-network-capture-browser/src/track-network-event.ts
index 4ee940962..4dd331822 100644
--- a/packages/plugin-network-capture-browser/src/track-network-event.ts
+++ b/packages/plugin-network-capture-browser/src/track-network-event.ts
@@ -136,6 +136,7 @@ export type NetworkAnalyticsEvent = {
   ['[Amplitude] Duration']?: number; // completionTime - startTime (millis)
   ['[Amplitude] Request Body Size']?: number;
   ['[Amplitude] Response Body Size']?: number;
+  ['[Amplitude] Request Type']?: 'xhr' | 'fetch';
 };
 
 export function trackNetworkEvents({
@@ -185,6 +186,7 @@ export function trackNetworkEvents({
       ['[Amplitude] Duration']: request.duration,
       ['[Amplitude] Request Body Size']: requestBodySize,
       ['[Amplitude] Response Body Size']: responseBodySize,
+      ['[Amplitude] Request Type']: request.type,
     };
 
     /* istanbul ignore next */
diff --git a/packages/plugin-network-capture-browser/test/autocapture-plugin/track-network-event.test.ts b/packages/plugin-network-capture-browser/test/autocapture-plugin/track-network-event.test.ts
index acd1bdeb4..792a8ca7f 100644
--- a/packages/plugin-network-capture-browser/test/autocapture-plugin/track-network-event.test.ts
+++ b/packages/plugin-network-capture-browser/test/autocapture-plugin/track-network-event.test.ts
@@ -23,10 +23,12 @@ import * as streams from 'stream/web';
 import { TextEncoder } from 'util';
 type PartialGlobal = Pick<typeof globalThis, 'fetch'>;
 
+type ResourceType = 'fetch' | 'xhr';
+
 class MockNetworkRequestEvent implements NetworkRequestEvent {
   constructor(
     public url: string = 'https://example.com',
-    public type: string = 'fetch',
+    public type: ResourceType = 'fetch',
     public method: string = 'GET',
     public status: number = 200,
     public duration: number = 100,
@@ -185,6 +187,7 @@ describe('track-network-event', () => {
         '[Amplitude] Duration': expect.any(Number),
         '[Amplitude] Request Body Size': 10,
         '[Amplitude] Response Body Size': 100,
+        '[Amplitude] Request Type': 'fetch',
       });
     });
 
@@ -192,7 +195,7 @@ describe('track-network-event', () => {
       eventCallbacks.forEach((cb: NetworkEventCallback) => {
         cb.callback({
           url: 'https://example.com/track?hello=world#hash',
-          type: 'fetch',
+          type: 'xhr',
           method: 'POST',
           status: 500,
           duration: 100,
@@ -222,6 +225,7 @@ describe('track-network-event', () => {
         '[Amplitude] Duration': expect.any(Number),
         '[Amplitude] Request Body Size': undefined,
         '[Amplitude] Response Body Size': undefined,
+        '[Amplitude] Request Type': 'xhr',
       });
     });
 
@@ -263,7 +267,7 @@ describe('track-network-event', () => {
           startTime: Date.now(),
           timestamp: Date.now(),
           endTime: Date.now() + 100,
-        };
+        } as NetworkRequestEvent;
         cb.callback({
           ...event,
           toSerializable: () => event,
diff --git a/test-server/browser-sdk/fetch.html b/test-server/browser-sdk/fetch.html
new file mode 100644
index 000000000..31251fc8b
--- /dev/null
+++ b/test-server/browser-sdk/fetch.html
@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <title>Fetch & XHR Network Tracking Test</title>
+  </head>
+  <body>
+    <h1>Network Tracking Manual Test Suite</h1>
+    <div id="app">
+      <p>Review the console log for results.</p>
+    </div>
+    <script type="module">
+      import * as amplitude from '@amplitude/analytics-browser';
+      window.amplitude = amplitude;
+
+      amplitude.init(
+        import.meta.env.VITE_AMPLITUDE_API_KEY,
+        import.meta.env.VITE_AMPLITUDE_USER_ID || 'amplitude-typescript test user',
+        {
+          autocapture: true,
+          networkTrackingOptions: {
+            ignoreAmplitudeRequests: false,
+            ignoreHosts: [],
+            captureRules: [
+              { hosts: ['*.amplitude.com'], statusCodeRange: '400-599' },
+              { hosts: ['httpstat.us'], statusCodeRange: '0,400-599' },
+              { hosts: ['*'], statusCodeRange: '0,300-599' },
+            ],
+          },
+        }
+      );
+
+      const makeRequest = async (url, options) => {
+        try {
+          console.log('fetching', url, options);
+          const res = await fetch(url, options);
+          const contentType = res.headers.get('content-type') || '';
+          let data = contentType.includes('application/json') ? await res.json() : await res.text();
+          return {res, data, url, options};
+        } catch (error) {
+          console.error('Fetch error:', error);
+          return { status: 0, error };
+        }
+      };
+
+      function assert({res}) {
+        if (!(res instanceof Response)) {
+          throw new Error(`Test failed to receive a Response object`);
+        }
+      }
+
+      setTimeout(async () => {
+        let res;
+
+        const controller = new AbortController();
+        setTimeout(() => controller.abort(), 10); // abort after 10ms
+
+        try {
+          const abortedRes = await fetch('https://httpstat.us/200', {
+            method: 'POST',
+            body: 'This request will be aborted',
+            headers: { 'Content-Type': 'text/plain' },
+            signal: controller.signal,
+          });
+          const result = await abortedRes.text();
+          console.log('Response:', result);
+        } catch (err) {
+          console.error('Aborted request error:', err.name);  // Should print "AbortError"
+        }
+
+
+        // Original test cases
+        res = await makeRequest('https://httpstat.us/500', { method: 'POST', body: 'Hello', headers: { 'Content-Type': 'text/plain' } });
+        assert(res);
+        
+        res = await makeRequest('https://httpbin.org/status/501', { method: 'POST', body: JSON.stringify({ message: 'Hello', count: 42 }), headers: { 'Content-Type': 'application/json' } });
+        assert(res);
+        
+        const formData = new FormData();
+        formData.append('text', 'Hello from FormData');
+        formData.append('file', new Blob(['Hello from Blob'], { type: 'text/plain' }));
+        await makeRequest('https://httpbin.org/status/502', { method: 'POST', body: formData });
+        assert(res);
+        const params = new URLSearchParams();
+        params.append('param1', 'value1');
+        params.append('param2', 'value2');
+        await makeRequest('https://httpstat.us/503', { method: 'POST', body: params });
+        assert(res);
+        await makeRequest('https://httpstat.us/504', { method: 'POST', body: new ArrayBuffer(8) });
+        assert(res);
+        await makeRequest('https://httpstat.us/505', { method: 'POST', body: new Blob(['Hello from Blob'], { type: 'text/plain' }) });
+        assert(res);
+        const encoder = new TextEncoder();
+        await makeRequest('https://httpstat.us/506', { method: 'POST', body: encoder.encode('Hello from Stream') });
+        assert(res);
+        await makeRequest('https://httpstat.us/507', { method: 'GET' });
+        assert(res);
+        await makeRequest('https://httpstat.us/200', { method: 'GET' });
+        assert(res);
+
+        // Additional test cases
+        await makeRequest('https://httpstat.us/200', { method: 'HEAD' });
+        assert(res);
+        await makeRequest('https://httpstat.us/200', { method: 'OPTIONS' });
+        assert(res);
+        await makeRequest('https://httpstat.us/200', { method: 'DELETE' });
+        assert(res);
+        await makeRequest('https://httpstat.us/200', { method: 'PUT', body: JSON.stringify({ message: 'Update' }), headers: { 'Content-Type': 'application/json' } });
+        assert(res);
+        await makeRequest('/api/test', { method: 'GET' }); // Relative URL, expect 404 or dev server response
+        assert(res);
+        await makeRequest('https://example.com/non-cors-endpoint'); // Likely CORS failure
+        assert(res);
+        await makeRequest('https://invalid.domain.test'); // Unresolvable domain
+        assert(res);
+        await makeRequest('https://httpstat.us/200?sleep=5000'); // Slow request
+        assert(res);
+
+        console.log('Manual testing complete. Review console for outputs.');
+      }, 1000);
+    </script>
+  </body>
+</html>
diff --git a/test-server/browser-sdk/xhr.html b/test-server/browser-sdk/xhr.html
new file mode 100644
index 000000000..5e227ec80
--- /dev/null
+++ b/test-server/browser-sdk/xhr.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <title>XHR & Axios Network Tracking Test</title>
+    <script src="https://cdn.jsdelivr.net/npm/axios@1.9.0/dist/axios.js"></script>
+  </head>
+  <body>
+    <h1>XHR Network Tracking Manual Test Suite (Axios-Based)</h1>
+    <div>
+      <p>Review the console log for results.</p>
+    </div>
+    <script type="module">
+      import * as amplitude from '@amplitude/analytics-browser';
+      window.amplitude = amplitude;
+
+      axios.defaults.validateStatus = () => true;
+
+      amplitude.init(
+        import.meta.env.VITE_AMPLITUDE_API_KEY,
+        import.meta.env.VITE_AMPLITUDE_USER_ID || 'amplitude-typescript test user',
+        {
+          networkTrackingOptions: {
+            ignoreAmplitudeRequests: false,
+            ignoreHosts: [],
+            captureRules: [
+              { hosts: ['*.amplitude.com'], statusCodeRange: '400-599' },
+              { hosts: ['httpstat.us'], statusCodeRange: '0,400-599' },
+              { hosts: ['*'], statusCodeRange: '0,300-599' },
+            ],
+          },
+        }
+      );
+
+      setTimeout(async () => {
+        let res;
+
+        const controller = new AbortController();
+        const signal = controller.signal;
+        try {
+          res = axios.post('https://httpstat.us/200', 'This request will be aborted', { signal });
+          controller.abort();
+          const awaitedRes = await res;
+          console.log('Aborted request response (should not log):', awaitedRes);
+        } catch (err) {
+          console.log('Aborted request error:', err);
+        }
+
+        // Basic GET 200
+        res = await axios.get('https://httpstat.us/200');
+        console.log('GET 200 response:', res);
+
+        // String body
+        res = await axios.post('https://httpstat.us/500', 'Hello, this is a test body');
+        console.log('String body response:', res);
+
+        // JSON object body
+        res = await axios.post('https://httpstat.us/501', { message: 'Hello', count: 42 });
+        console.log('JSON object body response:', res);
+
+        // FormData body
+        const formData = new FormData();
+        formData.append('text', 'Hello from FormData');
+        formData.append('file', new Blob(['Hello from Blob'], { type: 'text/plain' }));
+        res = await axios.post('https://httpstat.us/502', formData);
+        console.log('FormData body response:', res);
+
+        // URLSearchParams body
+        const params = new URLSearchParams();
+        params.append('param1', 'value1');
+        params.append('param2', 'value2');
+        res = await axios.post('https://httpstat.us/504', params);
+        console.log('URLSearchParams body response:', res);
+
+        // ArrayBuffer body
+        const buffer = new ArrayBuffer(8);
+        res = await axios.post('https://httpstat.us/505', buffer);
+        console.log('ArrayBuffer body response:', res);
+
+        // Blob body
+        const blob = new Blob(['Hello from Blob'], { type: 'text/plain' });
+        res = await axios.post('https://httpstat.us/506', blob);
+        console.log('Blob body response:', res);
+
+        // Stream body (Not natively supported by Axios, but included to match fetch tests)
+        try {
+          const stream = new ReadableStream({
+            start(controller) {
+              controller.enqueue('Hello from Stream');
+              controller.close();
+            },
+          });
+          res = await axios.post('https://httpstat.us/507', stream);
+          console.log('Stream body response:', res);
+        } catch (err) {
+          console.log('Stream body not supported by Axios:', err);
+        }
+
+        // GET 508
+        res = await axios.get('https://httpstat.us/508');
+        console.log('GET 508 response:', res);
+
+        // Additional HTTP Methods
+        res = await axios.head('https://httpstat.us/200');
+        console.log('HEAD 200 response:', res);
+
+        res = await axios.options('https://httpstat.us/200');
+        console.log('OPTIONS 200 response:', res);
+
+        res = await axios.delete('https://httpstat.us/200');
+        console.log('DELETE 200 response:', res);
+
+        res = await axios.put('https://httpstat.us/200', { update: true });
+        console.log('PUT 200 response:', res);
+
+        // Relative URL (likely 404)
+        try {
+          res = await axios.get('/api/test');
+          console.log('Relative URL response:', res);
+        } catch (err) {
+          console.log('Relative URL error:', err);
+        }
+
+        // CORS failure simulation
+        try {
+          res = await axios.get('https://example.com/non-cors-endpoint');
+          console.log('CORS failure response:', res);
+        } catch (err) {
+          console.log('CORS failure error:', err);
+        }
+
+        // Network failure (invalid domain)
+        try {
+          res = await axios.get('https://invalid.domain.test');
+          console.log('Network failure response:', res);
+        } catch (err) {
+          console.log('Network failure error:', err);
+        }
+
+        // Delayed response
+        res = await axios.get('https://httpstat.us/200?sleep=5000');
+        console.log('Delayed GET 200 response:', res);
+
+        console.log('Manual testing complete. Review console for outputs.');
+      }, 1000);
+    </script>
+  </body>
+</html>


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR enhances network tracking by adding XHR observation capabilities alongside existing fetch instrumentation and expands the network request event interfaces to support additional body types.  
- Added a new test page (xhr.html) demonstrating various XHR request payloads.  
- Extended network request wrappers and response wrappers to include new types and XHR-specific implementations.  
- Updated the NetworkObserver to capture XHR events via prototype overrides.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File | Description |
| ---- | ----------- |
| test-server/browser-sdk/xhr.html | New test page showcasing various XHR payloads and error simulations. |
| packages/analytics-core/test/network-observer.test.ts | Added tests for observing XHR events with a mock XMLHttpRequest implementation and adjusted header expectations. |
| packages/analytics-core/src/network-request-event.ts | Extended types for request bodies and refactored request/response wrappers to support additional body types. |
| packages/analytics-core/src/network-observer.ts | Updated NetworkObserver to override XMLHttpRequest methods and emit events accordingly. |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T03:46:53Z
user: login: jxiwang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:06:29Z
user: login: jxiwang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:06:49Z
user: login: jxiwang
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:07:31Z
user: login: daniel-graham-amplitude
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T15:29:52Z
user: login: jxiwang
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-16T16:47:58Z
user: login: Mercy811
body: Overall LGTM. Thanks @daniel-graham-amplitude.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T18:04:06Z
user: login: daniel-graham-amplitude
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T18:13:48Z
user: login: daniel-graham-amplitude
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T18:29:16Z
user: login: daniel-graham-amplitude
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T18:29:45Z

Review comments:
diff_hunk: @@ -165,7 +165,7 @@ describe('NetworkObserver', () => {
       });
       expect(events[0].duration).toBeGreaterThanOrEqual(0);
       expect(events[0].requestWrapper?.headers).toEqual(undefined);
-      expect(events[0].responseWrapper?.headers).toEqual({});
+      expect(events[0].responseWrapper?.headers).toEqual(undefined);
path: packages/analytics-core/test/network-observer.test.ts
user: login: Copilot
body: There is an inconsistency in the expected default value for responseWrapper.headers compared to the ResponseWrapperXhr (which returns an empty object {}). Consider unifying the behavior across both implementations for consistency.
created_at: 2025-05-13T03:46:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,56 +75,88 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
     return this.request.method;
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: Document | XMLHttpRequestBodyInit | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(body: FetchRequestBody, maxEntries: number): number | undefined {
path: packages/analytics-core/src/network-request-event.ts
user: login: Copilot
body: [nitpick] New types (Document, ArrayBufferView) have been added to FetchRequestBody but are not explicitly handled in the body size estimation logic. Consider adding documentation or implementing size estimation for these types if necessary.
created_at: 2025-05-13T03:46:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,58 +136,94 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
     return this.request.method;
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: XMLHttpRequestBodyInitSafe | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(bodyUnsafe: FetchRequestBody, maxEntries: number): number | undefined {
+  let bodySize: number | undefined;
+  const global = getGlobalScope();
+  /* istanbul ignore next */
+  const TextEncoder = global?.TextEncoder;
+  /* istanbul ignore next */
+  if (!TextEncoder) {
+    return;
+  }
+  let body;
+  if (typeof bodyUnsafe === 'string') {
+    body = bodyUnsafe;
+    bodySize = new TextEncoder().encode(body).length;
+  } else if (bodyUnsafe instanceof Blob) {
+    body = bodyUnsafe as BlobSafe;
+    bodySize = bodyUnsafe.size;
+  } else if (bodyUnsafe instanceof URLSearchParams) {
+    body = bodyUnsafe as URLSearchParamsSafe;
+    bodySize = new TextEncoder().encode(bodyUnsafe.toString()).length;
path: packages/analytics-core/src/network-request-event.ts
user: login: jxiwang
body: Should this be
`new TextEncoder().encode(body.toString()).length;`?
created_at: 2025-05-15T22:06:29Z
author_association: COLLABORATOR
diff_hunk: @@ -65,58 +136,94 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
     return this.request.method;
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: XMLHttpRequestBodyInitSafe | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(bodyUnsafe: FetchRequestBody, maxEntries: number): number | undefined {
+  let bodySize: number | undefined;
+  const global = getGlobalScope();
+  /* istanbul ignore next */
+  const TextEncoder = global?.TextEncoder;
+  /* istanbul ignore next */
+  if (!TextEncoder) {
+    return;
+  }
+  let body;
+  if (typeof bodyUnsafe === 'string') {
+    body = bodyUnsafe;
+    bodySize = new TextEncoder().encode(body).length;
+  } else if (bodyUnsafe instanceof Blob) {
+    body = bodyUnsafe as BlobSafe;
+    bodySize = bodyUnsafe.size;
path: packages/analytics-core/src/network-request-event.ts
user: login: jxiwang
body: Should this be `body.size` ?
created_at: 2025-05-15T22:06:49Z
author_association: COLLABORATOR
diff_hunk: @@ -65,58 +136,94 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
     return this.request.method;
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: XMLHttpRequestBodyInitSafe | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(bodyUnsafe: FetchRequestBody, maxEntries: number): number | undefined {
+  let bodySize: number | undefined;
+  const global = getGlobalScope();
+  /* istanbul ignore next */
+  const TextEncoder = global?.TextEncoder;
+  /* istanbul ignore next */
+  if (!TextEncoder) {
+    return;
+  }
+  let body;
+  if (typeof bodyUnsafe === 'string') {
+    body = bodyUnsafe;
+    bodySize = new TextEncoder().encode(body).length;
+  } else if (bodyUnsafe instanceof Blob) {
+    body = bodyUnsafe as BlobSafe;
+    bodySize = bodyUnsafe.size;
+  } else if (bodyUnsafe instanceof URLSearchParams) {
+    body = bodyUnsafe as URLSearchParamsSafe;
+    bodySize = new TextEncoder().encode(bodyUnsafe.toString()).length;
+  } else if (ArrayBuffer.isView(bodyUnsafe)) {
+    body = bodyUnsafe as ArrayBufferViewSafe;
path: packages/analytics-core/src/network-request-event.ts
user: login: jxiwang
body: Maybe we do `bodySafe` here like below? Same with the rest of the typings. 
created_at: 2025-05-15T22:07:31Z
author_association: COLLABORATOR
diff_hunk: @@ -65,58 +136,94 @@ export class RequestWrapper {
     if (!global?.TextEncoder) {
       return;
     }
-    const { TextEncoder } = global;
     const body = this.request.body as FetchRequestBody;
-
-    let bodySize: number | undefined;
-    if (typeof body === 'string') {
-      bodySize = new TextEncoder().encode(body).length;
-    } else if (body instanceof Blob) {
-      bodySize = body.size;
-    } else if (body instanceof URLSearchParams) {
-      bodySize = new TextEncoder().encode(body.toString()).length;
-    } else if (ArrayBuffer.isView(body)) {
-      bodySize = body.byteLength;
-    } else if (body instanceof ArrayBuffer) {
-      bodySize = body.byteLength;
-    } else if (body instanceof FormData) {
-      // Estimating only for text parts; not accurate for files
-      const formData = body as FormDataBrowser;
-
-      let total = 0;
-      let count = 0;
-      for (const [key, value] of formData.entries()) {
-        total += key.length;
-        if (typeof value === 'string') {
-          total += new TextEncoder().encode(value).length;
-        } else if (value instanceof Blob) {
-          total += value.size;
-        } else {
-          // encountered another unknown type
-          // we can't estimate the size of this entry
-          this._bodySize = undefined;
-          return;
-        }
-        // terminate if we reach the maximum number of entries
-        // to avoid performance issues in case of very large FormData
-        if (++count >= this.MAXIMUM_ENTRIES) {
-          this._bodySize = undefined;
-          return;
-        }
-      }
-      bodySize = total;
-    }
-    this._bodySize = bodySize;
-    return bodySize;
+    this._bodySize = getBodySize(body, MAXIMUM_ENTRIES);
+    return this._bodySize;
   }
 
   get method(): string | undefined {
     return this.request.method;
   }
 }
 
+export class RequestWrapperXhr implements IRequestWrapper {
+  constructor(readonly body: XMLHttpRequestBodyInitSafe | null) {}
+
+  get bodySize(): number | undefined {
+    return getBodySize(this.body as FetchRequestBody, MAXIMUM_ENTRIES);
+  }
+}
+
+function getBodySize(bodyUnsafe: FetchRequestBody, maxEntries: number): number | undefined {
+  let bodySize: number | undefined;
+  const global = getGlobalScope();
+  /* istanbul ignore next */
+  const TextEncoder = global?.TextEncoder;
+  /* istanbul ignore next */
+  if (!TextEncoder) {
+    return;
+  }
+  let body;
+  if (typeof bodyUnsafe === 'string') {
+    body = bodyUnsafe;
+    bodySize = new TextEncoder().encode(body).length;
+  } else if (bodyUnsafe instanceof Blob) {
+    body = bodyUnsafe as BlobSafe;
+    bodySize = bodyUnsafe.size;
+  } else if (bodyUnsafe instanceof URLSearchParams) {
+    body = bodyUnsafe as URLSearchParamsSafe;
+    bodySize = new TextEncoder().encode(bodyUnsafe.toString()).length;
+  } else if (ArrayBuffer.isView(bodyUnsafe)) {
+    body = bodyUnsafe as ArrayBufferViewSafe;
path: packages/analytics-core/src/network-request-event.ts
user: login: daniel-graham-amplitude
body: I renamed them all to be explicitly "bodySafe"
created_at: 2025-05-16T15:29:51Z
author_association: COLLABORATOR
path: packages/analytics-core/src/network-observer.ts
user: login: Mercy811
body: nit: although it's not in code changes, also check if xhr is supported at `isSupported()` 
created_at: 2025-05-16T17:51:39Z
author_association: CONTRIBUTOR
path: test-server/browser-sdk/xhr.html
user: login: Mercy811
body: It's great to have an example html app. Can we have jest integration tests so that it's also in CI? 
created_at: 2025-05-16T17:54:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,15 +80,25 @@ export class NetworkObserver {
     }
     this.eventCallbacks.set(eventCallback.id, eventCallback);
     if (!this.isObserving) {
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrOpen = this.globalScope?.XMLHttpRequest?.prototype?.open;
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrSend = this.globalScope?.XMLHttpRequest?.prototype?.send;
path: packages/analytics-core/src/network-observer.ts
user: login: Mercy811
body: Looks like we never restore to origin. I understand that override won't persist when page reload (jump to another website). But just thinking about some edge cases here.  
created_at: 2025-05-16T18:03:21Z
author_association: CONTRIBUTOR
path: test-server/browser-sdk/xhr.html
user: login: daniel-graham-amplitude
body: There's `network-observer.test.ts` which gives pretty good coverage. But it doesn't make actual network calls.

Do we have integration tests currently? I would love to have a place where I can make these calls in an automated way.
created_at: 2025-05-16T18:13:48Z
author_association: COLLABORATOR
path: packages/analytics-core/src/network-observer.ts
user: login: daniel-graham-amplitude
body: Good thinking! Done.
created_at: 2025-05-16T18:29:16Z
author_association: COLLABORATOR
diff_hunk: @@ -43,15 +80,25 @@ export class NetworkObserver {
     }
     this.eventCallbacks.set(eventCallback.id, eventCallback);
     if (!this.isObserving) {
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrOpen = this.globalScope?.XMLHttpRequest?.prototype?.open;
+      /* istanbul ignore next */
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalXhrSend = this.globalScope?.XMLHttpRequest?.prototype?.send;
path: packages/analytics-core/src/network-observer.ts
user: login: daniel-graham-amplitude
body: Restoring has the same problem we had with pushState. If we restore it back to the origin, then anyone else who overwrote `open` or `send` will have the overrides replaced.
created_at: 2025-05-16T18:29:45Z
author_association: COLLABORATOR

PR comments:
user: login: promptless[bot]
created_at: 2025-05-14T02:19:27Z
body: 📝 Documentation updates detected! You can review documentation updates [here](https://app.gopromptless.ai/change-history/8140ec90-92f0-47d8-9483-d42f807cf113)
author_association: NONE
user: login: Mercy811
created_at: 2025-05-15T17:41:03Z
body: Where is `instrumentXhrAndFetchBeta`? I didn't a search on the PR and repo but didn't find it. Is that part of the public SDK config? 
author_association: CONTRIBUTOR
user: login: daniel-graham-amplitude
created_at: 2025-05-15T17:45:33Z
body: > Where is `instrumentXhrAndFetchBeta`? I didn't a search on the PR and repo but didn't find it. Is that part of the public SDK config?

My mistake, I forgot to push it. 🤦 

It's there now.
author_association: COLLABORATOR