1
- import { getGlobalScope } from './global-scope ' ;
1
+ import { getGlobalScope } from './' ;
2
2
import { UUID } from './utils/uuid' ;
3
3
import { ILogger } from '.' ;
4
+ import { NetworkRequestEvent , RequestWrapper , ResponseWrapper } from './network-request-event' ;
4
5
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 ;
74
11
}
75
12
76
13
export type NetworkEventCallbackFn = ( event : NetworkRequestEvent ) => void ;
@@ -80,12 +17,11 @@ export class NetworkEventCallback {
80
17
}
81
18
82
19
export class NetworkObserver {
83
- private originalFetch ?: typeof fetch ;
84
20
private eventCallbacks : Map < string , NetworkEventCallback > = new Map ( ) ;
85
- private isObserving = false ;
86
21
// eslint-disable-next-line no-restricted-globals
87
22
private globalScope ?: typeof globalThis ;
88
23
private logger ?: ILogger ;
24
+ private isObserving = false ;
89
25
constructor ( logger ?: ILogger ) {
90
26
this . logger = logger ;
91
27
const globalScope = getGlobalScope ( ) ;
@@ -94,8 +30,6 @@ export class NetworkObserver {
94
30
return ;
95
31
}
96
32
this . globalScope = globalScope ;
97
- /* istanbul ignore next */
98
- this . originalFetch = this . globalScope ?. fetch ;
99
33
}
100
34
101
35
static isSupported ( ) : boolean {
@@ -109,91 +43,159 @@ export class NetworkObserver {
109
43
}
110
44
this . eventCallbacks . set ( eventCallback . id , eventCallback ) ;
111
45
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 */
113
53
this . isObserving = true ;
54
+ this . observeFetch ( originalFetch ) ;
114
55
}
115
56
}
116
57
117
58
unsubscribe ( eventCallback : NetworkEventCallback ) {
118
59
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
- }
123
60
}
124
61
125
62
protected triggerEventCallbacks ( event : NetworkRequestEvent ) {
126
63
this . eventCallbacks . forEach ( ( callback ) => {
127
64
try {
128
65
callback . callback ( event ) ;
129
66
} catch ( err ) {
67
+ // if the callback throws an error, we should catch it
68
+ // to avoid breaking the fetch promise chain
130
69
/* istanbul ignore next */
131
70
this . logger ?. debug ( 'an unexpected error occurred while triggering event callbacks' , err ) ;
132
71
}
133
72
} ) ;
134
73
}
135
74
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
+ ) {
137
83
/* 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
139
87
return ;
140
88
}
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' ,
154
110
} ;
111
+ status = 0 ;
112
+ }
155
113
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 ;
156
158
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 ( ) ;
178
160
} 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
+ }
181
164
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 ) {
182
170
// 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
+ }
189
173
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
+ }
194
192
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 ;
197
199
}
198
200
} ;
199
201
}
0 commit comments