Skip to content

Commit 6359e8c

Browse files
refactor: make improvements to NetworkObserver (#1074)
1 parent 3b1270c commit 6359e8c

File tree

6 files changed

+816
-311
lines changed

6 files changed

+816
-311
lines changed

packages/analytics-core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,6 @@ export {
6060
} from './types/browser-config';
6161
export { BrowserClient } from './types/browser-client';
6262

63-
export { NetworkRequestEvent, NetworkEventCallback, networkObserver } from './network-observer';
63+
export { NetworkEventCallback, networkObserver } from './network-observer';
64+
export { NetworkRequestEvent } from './network-request-event';
6465
export { NetworkTrackingOptions, NetworkCaptureRule } from './types/network-tracking';

packages/analytics-core/src/network-observer.ts

Lines changed: 131 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,13 @@
1-
import { getGlobalScope } from './global-scope';
1+
import { getGlobalScope } from './';
22
import { UUID } from './utils/uuid';
33
import { ILogger } from '.';
4+
import { NetworkRequestEvent, RequestWrapper, ResponseWrapper } from './network-request-event';
45

5-
const MAXIMUM_ENTRIES = 100;
6-
export interface NetworkRequestEvent {
7-
type: string;
8-
method: string;
9-
url: string;
10-
timestamp: number;
11-
status?: number;
12-
duration?: number;
13-
requestBodySize?: number;
14-
requestHeaders?: Record<string, string>;
15-
responseBodySize?: number;
16-
responseHeaders?: Record<string, string>;
17-
error?: {
18-
name: string;
19-
message: string;
20-
};
21-
startTime?: number;
22-
endTime?: number;
23-
}
24-
25-
// using this type instead of the DOM's ttp so that it's Node compatible
26-
type FormDataEntryValueBrowser = string | Blob | null;
27-
export interface FormDataBrowser {
28-
entries(): IterableIterator<[string, FormDataEntryValueBrowser]>;
29-
}
30-
31-
export type FetchRequestBody = string | Blob | ArrayBuffer | FormDataBrowser | URLSearchParams | null | undefined;
32-
33-
export function getRequestBodyLength(body: FetchRequestBody | null | undefined): number | undefined {
34-
const global = getGlobalScope();
35-
if (!global?.TextEncoder) {
36-
return;
37-
}
38-
const { TextEncoder } = global;
39-
40-
if (typeof body === 'string') {
41-
return new TextEncoder().encode(body).length;
42-
} else if (body instanceof Blob) {
43-
return body.size;
44-
} else if (body instanceof URLSearchParams) {
45-
return new TextEncoder().encode(body.toString()).length;
46-
} else if (body instanceof ArrayBuffer) {
47-
return body.byteLength;
48-
} else if (ArrayBuffer.isView(body)) {
49-
return body.byteLength;
50-
} else if (body instanceof FormData) {
51-
// Estimating only for text parts; not accurate for files
52-
const formData = body as FormDataBrowser;
53-
54-
let total = 0;
55-
let count = 0;
56-
for (const [key, value] of formData.entries()) {
57-
total += key.length;
58-
if (typeof value === 'string') {
59-
total += new TextEncoder().encode(value).length;
60-
} else if ((value as Blob).size) {
61-
// if we encounter a "File" type, we should not count it and just return undefined
62-
total += (value as Blob).size;
63-
}
64-
// terminate if we reach the maximum number of entries
65-
// to avoid performance issues in case of very large FormDataß
66-
if (++count >= MAXIMUM_ENTRIES) {
67-
return;
68-
}
69-
}
70-
return total;
71-
}
72-
// Stream or unknown
73-
return;
6+
/**
7+
* Typeguard function checks if an input is a Request object.
8+
*/
9+
function isRequest(requestInfo: any): requestInfo is Request {
10+
return typeof requestInfo === 'object' && requestInfo !== null && 'url' in requestInfo && 'method' in requestInfo;
7411
}
7512

7613
export type NetworkEventCallbackFn = (event: NetworkRequestEvent) => void;
@@ -80,12 +17,11 @@ export class NetworkEventCallback {
8017
}
8118

8219
export class NetworkObserver {
83-
private originalFetch?: typeof fetch;
8420
private eventCallbacks: Map<string, NetworkEventCallback> = new Map();
85-
private isObserving = false;
8621
// eslint-disable-next-line no-restricted-globals
8722
private globalScope?: typeof globalThis;
8823
private logger?: ILogger;
24+
private isObserving = false;
8925
constructor(logger?: ILogger) {
9026
this.logger = logger;
9127
const globalScope = getGlobalScope();
@@ -94,8 +30,6 @@ export class NetworkObserver {
9430
return;
9531
}
9632
this.globalScope = globalScope;
97-
/* istanbul ignore next */
98-
this.originalFetch = this.globalScope?.fetch;
9933
}
10034

10135
static isSupported(): boolean {
@@ -109,91 +43,159 @@ export class NetworkObserver {
10943
}
11044
this.eventCallbacks.set(eventCallback.id, eventCallback);
11145
if (!this.isObserving) {
112-
this.observeFetch();
46+
/* istanbul ignore next */
47+
const originalFetch = this.globalScope?.fetch;
48+
/* istanbul ignore next */
49+
if (!originalFetch) {
50+
return;
51+
}
52+
/* istanbul ignore next */
11353
this.isObserving = true;
54+
this.observeFetch(originalFetch);
11455
}
11556
}
11657

11758
unsubscribe(eventCallback: NetworkEventCallback) {
11859
this.eventCallbacks.delete(eventCallback.id);
119-
if (this.originalFetch && this.globalScope && this.eventCallbacks.size === 0 && this.isObserving) {
120-
this.globalScope.fetch = this.originalFetch;
121-
this.isObserving = false;
122-
}
12360
}
12461

12562
protected triggerEventCallbacks(event: NetworkRequestEvent) {
12663
this.eventCallbacks.forEach((callback) => {
12764
try {
12865
callback.callback(event);
12966
} catch (err) {
67+
// if the callback throws an error, we should catch it
68+
// to avoid breaking the fetch promise chain
13069
/* istanbul ignore next */
13170
this.logger?.debug('an unexpected error occurred while triggering event callbacks', err);
13271
}
13372
});
13473
}
13574

136-
private observeFetch() {
75+
private handleNetworkRequestEvent(
76+
requestInfo: RequestInfo | URL | undefined,
77+
requestWrapper: RequestWrapper | undefined,
78+
responseWrapper: ResponseWrapper | undefined,
79+
typedError: Error | undefined,
80+
startTime?: number,
81+
durationStart?: number,
82+
) {
13783
/* istanbul ignore next */
138-
if (!this.globalScope || !this.originalFetch) {
84+
if (startTime === undefined || durationStart === undefined) {
85+
// if we reach this point, it means that the performance API is not supported
86+
// so we can't construct a NetworkRequestEvent
13987
return;
14088
}
141-
const originalFetch = this.globalScope.fetch;
142-
143-
this.globalScope.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
144-
const startTime = Date.now();
145-
const durationStart = performance.now();
146-
const requestEvent: NetworkRequestEvent = {
147-
timestamp: startTime,
148-
startTime,
149-
type: 'fetch',
150-
method: init?.method || 'GET', // Fetch API defaulted to GET when no method is provided
151-
url: input.toString(),
152-
requestHeaders: init?.headers as Record<string, string>,
153-
requestBodySize: getRequestBodyLength(init?.body as FetchRequestBody),
89+
90+
// parse the URL and Method
91+
let url: string | undefined;
92+
let method = 'GET';
93+
if (isRequest(requestInfo)) {
94+
url = requestInfo['url'];
95+
method = requestInfo['method'];
96+
} else {
97+
url = requestInfo?.toString?.();
98+
}
99+
method = requestWrapper?.method || method;
100+
101+
let status, error;
102+
if (responseWrapper) {
103+
status = responseWrapper.status;
104+
}
105+
106+
if (typedError) {
107+
error = {
108+
name: typedError.name || 'UnknownError',
109+
message: typedError.message || 'An unknown error occurred',
154110
};
111+
status = 0;
112+
}
155113

114+
const duration = Math.floor(performance.now() - durationStart);
115+
const endTime = Math.floor(startTime + duration);
116+
117+
const requestEvent = new NetworkRequestEvent(
118+
'fetch',
119+
method,
120+
startTime, // timestamp and startTime are aliases
121+
startTime,
122+
url,
123+
requestWrapper,
124+
status,
125+
duration,
126+
responseWrapper,
127+
error,
128+
endTime,
129+
);
130+
131+
this.triggerEventCallbacks(requestEvent);
132+
}
133+
134+
getTimestamps() {
135+
/* istanbul ignore next */
136+
return {
137+
startTime: Date.now?.(),
138+
durationStart: performance?.now?.(),
139+
};
140+
}
141+
142+
private observeFetch(
143+
originalFetch: (requestInfo: RequestInfo | URL, requestInit?: RequestInit) => Promise<Response>,
144+
) {
145+
/* istanbul ignore next */
146+
if (!this.globalScope || !originalFetch) {
147+
return;
148+
}
149+
/**
150+
* IMPORTANT: This overrides window.fetch in browsers.
151+
* You probably never need to make changes to this function.
152+
* If you do, please be careful to preserve the original functionality of fetch
153+
* and make sure another developer who is an expert reviews this change throughly
154+
*/
155+
this.globalScope.fetch = async (requestInfo?: RequestInfo | URL, requestInit?: RequestInit) => {
156+
// 1: capture the start time and duration start time before the fetch call
157+
let timestamps;
156158
try {
157-
const response = await originalFetch(input, init);
158-
159-
requestEvent.status = response.status;
160-
requestEvent.duration = Math.floor(performance.now() - durationStart);
161-
requestEvent.startTime = startTime;
162-
requestEvent.endTime = Math.floor(startTime + requestEvent.duration);
163-
164-
// Convert Headers
165-
const headers: Record<string, string> = {};
166-
let contentLength: number | undefined = undefined;
167-
response.headers.forEach((value: string, key: string) => {
168-
headers[key] = value;
169-
if (key === 'content-length') {
170-
contentLength = parseInt(value, 10) || undefined;
171-
}
172-
});
173-
requestEvent.responseHeaders = headers;
174-
requestEvent.responseBodySize = contentLength;
175-
176-
this.triggerEventCallbacks(requestEvent);
177-
return response;
159+
timestamps = this.getTimestamps();
178160
} catch (error) {
179-
const endTime = Date.now();
180-
requestEvent.duration = endTime - startTime;
161+
/* istanbul ignore next */
162+
this.logger?.debug('an unexpected error occurred while retrieving timestamps', error);
163+
}
181164

165+
// 2. make the call to the original fetch and preserve the response or error
166+
let originalResponse, originalError;
167+
try {
168+
originalResponse = await originalFetch(requestInfo as RequestInfo | URL, requestInit);
169+
} catch (err) {
182170
// Capture error information
183-
const typedError = error as Error;
184-
185-
requestEvent.error = {
186-
name: typedError.name || 'UnknownError',
187-
message: typedError.message || 'An unknown error occurred',
188-
};
171+
originalError = err;
172+
}
189173

190-
if (typedError.name === 'AbortError') {
191-
requestEvent.error.name = 'AbortError';
192-
requestEvent.status = 0;
193-
}
174+
// 3. call the handler after the fetch call is done
175+
try {
176+
this.handleNetworkRequestEvent(
177+
requestInfo,
178+
requestInit ? new RequestWrapper(requestInit) : undefined,
179+
originalResponse ? new ResponseWrapper(originalResponse) : undefined,
180+
originalError as Error,
181+
/* istanbul ignore next */
182+
timestamps?.startTime,
183+
/* istanbul ignore next */
184+
timestamps?.durationStart,
185+
);
186+
} catch (err) {
187+
// this catch shouldn't be reachable, but keep it here for safety
188+
// because we're overriding the fetch function and better to be safe than sorry
189+
/* istanbul ignore next */
190+
this.logger?.debug('an unexpected error occurred while handling fetch', err);
191+
}
194192

195-
this.triggerEventCallbacks(requestEvent);
196-
throw error;
193+
// 4. return the original response or throw the original error
194+
if (originalResponse) {
195+
// if the response is not undefined, return it
196+
return originalResponse;
197+
} else {
198+
throw originalError;
197199
}
198200
};
199201
}

0 commit comments

Comments
 (0)