diff --git a/tracker/tracker-reactnative/example/ios/Podfile b/tracker/tracker-reactnative/example/ios/Podfile index 9fd152d93..e30e7d843 100644 --- a/tracker/tracker-reactnative/example/ios/Podfile +++ b/tracker/tracker-reactnative/example/ios/Podfile @@ -46,7 +46,7 @@ target 'RntrackerExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) Dir['../../'] - pod 'Openreplay', '~> 1.0.7' + pod 'Openreplay', '~> 1.0.8' target 'RntrackerExampleTests' do diff --git a/tracker/tracker-reactnative/example/ios/Podfile.lock b/tracker/tracker-reactnative/example/ios/Podfile.lock index c034dbbf7..73e62ca0c 100644 --- a/tracker/tracker-reactnative/example/ios/Podfile.lock +++ b/tracker/tracker-reactnative/example/ios/Podfile.lock @@ -17,10 +17,10 @@ PODS: - hermes-engine/Pre-built (= 0.72.4) - hermes-engine/Pre-built (0.72.4) - libevent (2.1.12) - - Openreplay (1.0.7): + - Openreplay (1.0.8): - DeviceKit - SWCompression - - openreplay-reactnative (0.1.1): + - openreplay-reactnative (0.1.2): - Openreplay - RCT-Folly (= 2021.07.22.00) - React-Core @@ -486,7 +486,7 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - libevent (~> 2.1.12) - - Openreplay (~> 1.0.7) + - Openreplay (~> 1.0.8) - openreplay-reactnative (from `../..`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) @@ -627,8 +627,8 @@ SPEC CHECKSUMS: glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b hermes-engine: 81191603c4eaa01f5e4ae5737a9efcf64756c7b2 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - Openreplay: 1361c805302bfa181a98a73b7f5536740126d06d - openreplay-reactnative: 4d76871d1cffc6227f265d34c0fc67a3d61ebbf3 + Openreplay: 61de0723de9fa3e1dfef9058b93680cdc479be99 + openreplay-reactnative: 6d30623f1f38e711db1cf1bd783a56b80f8a444e RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: c0569ecc035894e4a68baecb30fe6a7ea6e399f9 RCTTypeSafety: e90354072c21236e0bcf1699011e39acd25fea2f @@ -665,6 +665,6 @@ SPEC CHECKSUMS: SWCompression: 15e38b06c37077399a1b60bfecc1c2cd71f0ee99 Yoga: 3efc43e0d48686ce2e8c60f99d4e6bd349aff981 -PODFILE CHECKSUM: 09dc44ceb84074d4583819c2f2c91bbb3ecea54c +PODFILE CHECKSUM: 71d4a7e6c76578117b61227a2f4ab0838c7711bc COCOAPODS: 1.12.1 diff --git a/tracker/tracker-reactnative/example/src/App.tsx b/tracker/tracker-reactnative/example/src/App.tsx index a81051d49..cbca62e8d 100644 --- a/tracker/tracker-reactnative/example/src/App.tsx +++ b/tracker/tracker-reactnative/example/src/App.tsx @@ -13,6 +13,9 @@ export default function App() { process.env.REACT_APP_INGEST ); console.log('test', Openreplay.tracker, 123123); + Openreplay.patchNetwork( + global, () => false, {} + ) }; const setMedatada = () => { @@ -27,6 +30,14 @@ export default function App() { Openreplay.tracker.setUserID('react-native@connector.me'); }; + const apiTest = () => { + fetch('https://pokeapi.co/api/v2/pokemon/ditto').then((res) => { + return res.json() + }).then((res) => { + console.log(res) + }) + } + return ( @@ -48,6 +59,9 @@ export default function App() { event + + Request + implements ProxyHandler { + public resp: Response + public item: NetworkMessage + + constructor(resp: T, item: NetworkMessage) { + this.resp = resp + this.item = item + this.mockReader() + } + + public set(target: T, key: string, value: (args: any[]) => any) { + return Reflect.set(target, key, value) + } + + public get(target: T, key: string) { + const value = Reflect.get(target, key) + switch (key) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + return () => { + this.item.responseType = key.toLowerCase() + // @ts-ignore + return value.apply(target).then((resp: any) => { + this.item.response = getStringResponseByType(this.item.responseType, resp) + return resp + }) + } + } + if (typeof value === 'function') { + return value.bind(target) + } else { + return value + } + } + + protected mockReader() { + let readerReceivedValue: Uint8Array + if (!this.resp.body) { + // some browsers do not return `body` in some cases, like `OPTIONS` method + return + } + if (typeof this.resp.body.getReader !== 'function') { + return + } + const _getReader = this.resp.body.getReader + // @ts-ignore + this.resp.body.getReader = () => { + const reader = >_getReader.apply(this.resp.body) + + // when readyState is already 4, + // it's not a chunked stream, or it had already been read. + // so should not update status. + if (this.item.readyState === RequestState.DONE) { + return reader + } + + const _read = reader.read + const _cancel = reader.cancel + this.item.responseType = 'arraybuffer' + + // @ts-ignore + reader.read = () => { + return (>_read.apply(reader)).then( + (result: any) => { + if (!readerReceivedValue) { + // @ts-ignore + readerReceivedValue = new Uint8Array(result.value) + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const newValue = new Uint8Array(readerReceivedValue.length + result.value!.length) + newValue.set(readerReceivedValue) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newValue.set(result.value!, readerReceivedValue.length) + readerReceivedValue = newValue + } + this.item.endTime = performance.now() + this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime) + this.item.readyState = result.done ? 4 : 3 + this.item.statusText = result.done ? String(this.item.status) : 'Loading' + this.item.responseSize = readerReceivedValue.length + this.item.responseSizeText = formatByteSize(this.item.responseSize) + if (result.done) { + this.item.response = getStringResponseByType( + this.item.responseType, + readerReceivedValue, + ) + } + return result + }, + ) + } + + reader.cancel = (...args) => { + this.item.cancelState = 2 + this.item.statusText = 'Cancel' + this.item.endTime = performance.now() + this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime) + this.item.response = getStringResponseByType(this.item.responseType, readerReceivedValue) + return _cancel.apply(reader, args) + } + return reader + } + } +} + +export class FetchProxyHandler implements ProxyHandler { + constructor( + private readonly ignoredHeaders: boolean | string[], + private readonly sanitize: (data: RequestResponseData) => RequestResponseData, + private readonly sendMessage: (item: any) => void, + private readonly isServiceUrl: (url: string) => boolean, + ) {} + + public apply(target: T, _: typeof global, argsList: [RequestInfo | URL, RequestInit]) { + const input = argsList[0] + const init = argsList[1] + if ( + !input || + // @ts-ignore + (typeof input !== 'string' && !input?.url) + ) { + return >target.apply(global, argsList) + } + + const isORUrl = + input instanceof URL || typeof input === 'string' + ? this.isServiceUrl(String(input)) + : this.isServiceUrl(String(input.url)) + + if (isORUrl) { + return target.apply(global, argsList) + } + + const item = new NetworkMessage(this.ignoredHeaders, this.sanitize) + this.beforeFetch(item, input as RequestInfo, init) + + return (>target.apply(global, argsList)) + .then(this.afterFetch(item)) + .catch((e) => { + // mock finally + item.endTime = performance.now() + item.duration = item.endTime - (item.startTime || item.endTime) + throw e + }) + } + + protected beforeFetch(item: NetworkMessage, input: RequestInfo | string, init?: RequestInit) { + let url: URL, + method = 'GET', + requestHeader: any = {} + + // handle `input` content + if (typeof input === 'string') { + // when `input` is a string + method = init?.method || 'GET' + url = getURL(input) + requestHeader = init?.headers || {} + } else { + // when `input` is a `Request` object + method = input.method || 'GET' + url = getURL(input.url) + requestHeader = input.headers + } + + item.method = method + item.requestType = 'fetch' + item.requestHeader = requestHeader + item.url = url.toString() + item.name = (item.url.split('/')[3] || '') + item.url.split('?')[1] + item.status = 0 + item.statusText = 'Pending' + item.readyState = 1 + if (!item.startTime) { + // UNSENT + item.startTime = performance.now() + } + + if (Object.prototype.toString.call(requestHeader) === '[object Headers]') { + item.requestHeader = {} + for (const [key, value] of requestHeader) { + item.requestHeader[key] = value + } + } else { + item.requestHeader = requestHeader + } + + // save GET data + const search = url.toString().split('?')[1] + const searchParams = new URLSearchParams(search) + if (search && searchParams) { + item.getData = {} + for (const [key, value] of searchParams) { + item.getData[key] = value + } + } + + // save POST data + if (init?.body) { + item.requestData = genStringBody(init.body) + } + } + + protected afterFetch(item: NetworkMessage) { + return (resp: Response) => { + item.endTime = performance.now() + item.duration = item.endTime - (item.startTime || item.endTime) + item.status = resp.status + item.statusText = String(resp.status) + + let isChunked = false + item.header = {} + for (const [key, value] of resp.headers) { + item.header[key] = value + isChunked = value.toLowerCase().indexOf('chunked') > -1 ? true : isChunked + } + + if (isChunked) { + // when `transfer-encoding` is chunked, the response is a stream which is under loading, + // so the `readyState` should be 3 (Loading), + // and the response should NOT be `clone()` which will affect stream reading. + item.readyState = 3 + } else { + // Otherwise, not chunked, the response is not a stream, + // so it's completed and can be cloned for `text()` calling. + item.readyState = 4 + + void this.handleResponseBody(resp.clone(), item).then( + (responseValue: string | ArrayBuffer) => { + item.responseSize = + typeof responseValue === 'string' ? responseValue.length : responseValue.byteLength + item.responseSizeText = formatByteSize(item.responseSize) + item.response = getStringResponseByType(item.responseType, responseValue) + + const msg = item.getMessage() + this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5]) + }, + ) + } + + return new Proxy(resp, new ResponseProxyHandler(resp, item)) + } + } + + protected handleResponseBody(resp: Response, item: NetworkMessage) { + // parse response body by Content-Type + const contentType = resp.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + item.responseType = 'json' + return resp.text() + } else if ( + contentType && + (contentType.includes('text/html') || contentType.includes('text/plain')) + ) { + item.responseType = 'text' + return resp.text() + } else { + item.responseType = 'arraybuffer' + return resp.arrayBuffer() + } + } +} + +export default class FetchProxy { + public static create( + ignoredHeaders: boolean | string[], + sanitize: (data: RequestResponseData) => RequestResponseData, + sendMessage: (item: any) => void, + isServiceUrl: (url: string) => boolean, + tokenUrlMatcher?: (url: string) => boolean, + ) { + return new Proxy( + fetch, + new FetchProxyHandler( + ignoredHeaders, + sanitize, + sendMessage, + isServiceUrl, + tokenUrlMatcher, + ), + ) + } +} diff --git a/tracker/tracker-reactnative/src/Network/index.ts b/tracker/tracker-reactnative/src/Network/index.ts new file mode 100644 index 000000000..3299de096 --- /dev/null +++ b/tracker/tracker-reactnative/src/Network/index.ts @@ -0,0 +1,31 @@ +import FetchProxy from './fetchProxy' +import XHRProxy from './xhrProxy' +import type { RequestResponseData } from './types' + +export default function setProxy( + context: typeof globalThis, + ignoredHeaders: boolean | string[], + sanitize: (data: RequestResponseData) => RequestResponseData, + sendMessage: (message: any) => void, + isServiceUrl: (url: string) => boolean, + tokenUrlMatcher?: (url: string) => boolean, +) { + if (context.XMLHttpRequest) { + context.XMLHttpRequest = XHRProxy.create( + ignoredHeaders, + sanitize, + sendMessage, + isServiceUrl, + tokenUrlMatcher, + ) + } + if (context.fetch) { + context.fetch = FetchProxy.create( + ignoredHeaders, + sanitize, + sendMessage, + isServiceUrl, + tokenUrlMatcher, + ) + } +} diff --git a/tracker/tracker-reactnative/src/Network/networkMessage.ts b/tracker/tracker-reactnative/src/Network/networkMessage.ts new file mode 100644 index 000000000..005cd3181 --- /dev/null +++ b/tracker/tracker-reactnative/src/Network/networkMessage.ts @@ -0,0 +1,109 @@ +import type { RequestResponseData } from './types'; + +export type httpMethod = + // '' is a rare case of error + | '' + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'HEAD' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE' + | 'PATCH'; + +export enum RequestState { + UNSENT = 0, + OPENED = 1, + HEADERS_RECEIVED = 2, + LOADING = 3, + DONE = 4, +} + +/** + * I know we're not using most of the information from this class + * but it can be useful in the future if we will decide to display more stuff in our ui + * */ + +export default class NetworkMessage { + id = ''; + name?: string = ''; + method: httpMethod = ''; + url = ''; + status = 0; + statusText?: string = ''; + cancelState?: 0 | 1 | 2 | 3 = 0; + readyState?: RequestState = 0; + header: { [key: string]: string } = {}; + responseType: XMLHttpRequest['responseType'] = ''; + requestType: 'xhr' | 'fetch' | 'ping' | 'custom' | 'beacon' = 'xhr' + requestHeader: any = {}; + response: any; + responseSize = 0; // bytes + responseSizeText = ''; + startTime = 0; + endTime = 0; + duration = 0; + getData: { [key: string]: string } = {}; + requestData: string | null = null; + + constructor( + private readonly ignoredHeaders: boolean | string[] = [], + private readonly sanitize: ( + data: RequestResponseData + ) => RequestResponseData + ) {} + + getMessage() { + const { reqHs, resHs } = this.writeHeaders(); + const request = { + headers: reqHs, + body: + this.method === 'GET' ? JSON.stringify(this.getData) : this.requestData, + }; + const response = { headers: resHs, body: this.response }; + + const messageInfo = this.sanitize({ + url: this.url, + method: this.method, + status: this.status, + request, + response, + }); + + return [ + messageInfo.url, + messageInfo.method, + JSON.stringify(messageInfo.request), + JSON.stringify(messageInfo.response), + messageInfo.status ?? 0, + this.duration ?? 0, + ] + } + + writeHeaders() { + const reqHs: Record = {}; + Object.entries(this.requestHeader).forEach(([key, value]) => { + if (this.isHeaderIgnored(key)) return; + // @ts-ignore + reqHs[key] = value; + }); + const resHs: Record = {}; + Object.entries(this.header).forEach(([key, value]) => { + if (this.isHeaderIgnored(key)) return; + resHs[key] = value; + }); + return { reqHs, resHs }; + } + + isHeaderIgnored(key: string) { + if (Array.isArray(this.ignoredHeaders)) { + return this.ignoredHeaders + .map((k) => k.toLowerCase()) + .includes(key.toLowerCase()); + } else { + return this.ignoredHeaders; + } + } +} diff --git a/tracker/tracker-reactnative/src/Network/types.ts b/tracker/tracker-reactnative/src/Network/types.ts new file mode 100644 index 000000000..14364700e --- /dev/null +++ b/tracker/tracker-reactnative/src/Network/types.ts @@ -0,0 +1,15 @@ +export interface RequestResponseData { + readonly status: number + readonly method: string + url: string + request: { + body: string | null + headers: Record + } + response: { + body: string | null + headers: Record + } +} + +// we only support sanitizing for json/string data because how you're gonna sanitize binary data? diff --git a/tracker/tracker-reactnative/src/Network/utils.ts b/tracker/tracker-reactnative/src/Network/utils.ts new file mode 100644 index 000000000..d325ee5b6 --- /dev/null +++ b/tracker/tracker-reactnative/src/Network/utils.ts @@ -0,0 +1,207 @@ +// @ts-nocheck + +export const genResponseByType = ( + responseType: XMLHttpRequest['responseType'], + response: any, +): string | Record => { + let result = '' + switch (responseType) { + case '': + case 'text': + case 'json': + if (typeof response == 'string') { + try { + result = JSON.parse(response) + } catch (e) { + // not a JSON string + result = response.slice(0, 10000) + } + } else if (isPureObject(response) || Array.isArray(response)) { + result = JSON.stringify(response) + } else if (typeof response !== 'undefined') { + result = Object.prototype.toString.call(response) + } + break + + case 'blob': + case 'document': + case 'arraybuffer': + default: + if (typeof response !== 'undefined') { + result = Object.prototype.toString.call(response) + } + break + } + return result +} + +export const getStringResponseByType = ( + responseType: XMLHttpRequest['responseType'], + response: any, +) => { + let result = '' + switch (responseType) { + case '': + case 'text': + case 'json': + if (typeof response == 'string') { + result = response + } else if (isPureObject(response) || Array.isArray(response)) { + result = JSON.stringify(response) + } else if (typeof response !== 'undefined') { + result = Object.prototype.toString.call(response) + } + break + case 'blob': + case 'document': + case 'arraybuffer': + default: + if (typeof response !== 'undefined') { + result = Object.prototype.toString.call(response) + } + break + } + return result +} + +export const genStringBody = (body?: BodyInit) => { + if (!body) { + return null + } + let result: string + + if (typeof body === 'string') { + if (body[0] === '{' || body[0] === '[') { + result = body + } + // 'a=1&b=2' => try to parse as query + const arr = body.split('&') + if (arr.length === 1) { + // not a query, parse as original string + result = body + } else { + // 'a=1&b=2&c' => parse as query + result = arr.join(',') + } + } else if (isIterable(body)) { + // FormData or URLSearchParams or Array + const arr = [] + for (const [key, value] of body) { + arr.push(`${key}=${typeof value === 'string' ? value : '[object Object]'}`) + } + result = arr.join(',') + } else if ( + body instanceof Blob || + body instanceof ReadableStream || + body instanceof ArrayBuffer + ) { + result = 'byte data' + } else if (isPureObject(body)) { + // overriding ArrayBufferView which is not convertable to string + result = body + } else { + result = `can't parse body ${typeof body}` + } + return result +} + +export const genGetDataByUrl = (url: string, getData: Record = {}) => { + if (!isPureObject(getData)) { + getData = {} + } + let query: string[] = url ? url.split('?') : [] // a.php?b=c&d=?e => ['a.php', 'b=c&d=', 'e'] + query.shift() // => ['b=c&d=', 'e'] + if (query.length > 0) { + query = query.join('?').split('&') // => 'b=c&d=?e' => ['b=c', 'd=?e'] + for (const q of query) { + const kv = q.split('=') + try { + getData[kv[0]] = decodeURIComponent(kv[1]) + } catch (e) { + // "URIError: URI malformed" will be thrown when `kv[1]` contains "%", so just use raw data + // @issue #470 + // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Malformed_URI + getData[kv[0]] = kv[1] + } + } + } + return getData +} + +export const genFormattedBody = (body?: BodyInit) => { + if (!body) { + return null + } + let result: string | { [key: string]: string } + + if (typeof body === 'string') { + try { + // '{a:1}' => + result = JSON.parse(body) + } catch (e) { + // 'a=1&b=2' => try to parse as query + const arr = body.split('&') + result = {} + // eslint-disable-next-line + for (let q of arr) { + const kv = q.split('=') + result[kv[0]] = kv[1] === undefined ? 'undefined' : kv[1] + } + } + } else if (isIterable(body)) { + // FormData or URLSearchParams or Array + result = {} + for (const [key, value] of body) { + result[key] = typeof value === 'string' ? value : '[object Object]' + } + } else if ( + body instanceof Blob || + body instanceof ReadableStream || + body instanceof ArrayBuffer + ) { + result = 'byte data' + } else if (isPureObject(body)) { + // overriding ArrayBufferView which is not convertable to string + result = body + } else { + result = `can't parse body ${typeof body}` + } + return result +} + +export function isPureObject(input: any): input is Record { + return null !== input && typeof input === 'object' +} + +export function isIterable(value: any) { + if (value === null || value === undefined) { + return false + } + return typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] === 'function' +} + +export function formatByteSize(bytes: number) { + if (bytes <= 0) { + // shouldn't happen? + return '' + } + if (bytes >= 1000 * 1000) { + return (bytes / 1000 / 1000).toFixed(1) + ' MB' + } + if (bytes >= 1000) { + return (bytes / 1000).toFixed(1) + ' KB' + } + return `${bytes}B` +} + +export const getURL = (urlString: string) => { + if (urlString.startsWith('//')) { + const baseUrl = new URL(window.location.href) + urlString = `${baseUrl.protocol}${urlString}` + } + if (urlString.startsWith('http')) { + return new URL(urlString) + } else { + return new URL(urlString, window.location.href) + } +} diff --git a/tracker/tracker-reactnative/src/Network/xhrProxy.ts b/tracker/tracker-reactnative/src/Network/xhrProxy.ts new file mode 100644 index 000000000..7a9f345b6 --- /dev/null +++ b/tracker/tracker-reactnative/src/Network/xhrProxy.ts @@ -0,0 +1,247 @@ +// @ts-nocheck +/** + * I took inspiration in few stack exchange posts + * and Tencent vConsole library (MIT) + * by wrapping the XMLHttpRequest object in a Proxy + * we can intercept the network requests + * in not-so-hacky way + * */ + +import NetworkMessage, { RequestState } from './networkMessage' +import { genGetDataByUrl, formatByteSize, genStringBody, getStringResponseByType } from './utils' +import { RequestResponseData } from './types' + +export class XHRProxyHandler implements ProxyHandler { + public XMLReq: XMLHttpRequest + public item: NetworkMessage + + constructor( + XMLReq: XMLHttpRequest, + private readonly ignoredHeaders: boolean | string[], + private readonly sanitize: (data: RequestResponseData) => RequestResponseData, + private readonly sendMessage: (message: any) => void, + private readonly isServiceUrl: (url: string) => boolean, + private readonly tokenUrlMatcher?: (url: string) => boolean, + ) { + this.XMLReq = XMLReq + this.XMLReq.onreadystatechange = () => { + this.onReadyStateChange() + } + this.XMLReq.onabort = () => { + this.onAbort() + } + this.XMLReq.ontimeout = () => { + this.onTimeout() + } + this.item = new NetworkMessage(ignoredHeaders, sanitize) + this.item.requestType = 'xhr' + } + + public get(target: T, key: string) { + switch (key) { + case 'open': + return this.getOpen(target) + case 'send': + return this.getSend(target) + case 'setRequestHeader': + return this.getSetRequestHeader(target) + default: + // eslint-disable-next-line no-case-declarations + const value = Reflect.get(target, key) + if (typeof value === 'function') { + return value.bind(target) + } else { + return value + } + } + } + + public set(target: T, key: string, value: (args: any[]) => any) { + switch (key) { + case 'onreadystatechange': + return this.setOnReadyStateChange(target, key, value) + case 'onabort': + return this.setOnAbort(target, key, value) + case 'ontimeout': + return this.setOnTimeout(target, key, value) + default: + // not tracked methods + } + return Reflect.set(target, key, value) + } + + public onReadyStateChange() { + if (this.item.url && this.isServiceUrl(this.item.url)) return + this.item.readyState = this.XMLReq.readyState + this.item.responseType = this.XMLReq.responseType + this.item.endTime = performance.now() + this.item.duration = this.item.endTime - this.item.startTime + this.updateItemByReadyState() + setTimeout(() => { + this.item.response = getStringResponseByType(this.item.responseType, this.item.response) + }, 0) + + if (this.XMLReq.readyState === RequestState.DONE) { + const msg = this.item.getMessage() + this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5]) + } + } + + public onAbort() { + this.item.cancelState = 1 + this.item.statusText = 'Abort' + + const msg = this.item.getMessage() + this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5]) + } + + public onTimeout() { + this.item.cancelState = 3 + this.item.statusText = 'Timeout' + + const msg = this.item.getMessage() + this.sendMessage(msg[0], msg[1], msg[2], msg[3], msg[4], msg[5]) + } + + protected getOpen(target: T) { + const targetFunction = Reflect.get(target, 'open') + return (...args: any[]) => { + const method = args[0] + const url = args[1] + this.item.method = method ? method.toUpperCase() : 'GET' + this.item.url = url || '' + this.item.name = this.item.url.replace(new RegExp('/*$'), '').split('/').pop() || '' + this.item.getData = genGetDataByUrl(this.item.url, {}) + return targetFunction.apply(target, args) + } + } + + protected getSend(target: T) { + const targetFunction = Reflect.get(target, 'send') + return (...args: any[]) => { + const data: XMLHttpRequestBodyInit = args[0] + this.item.requestData = genStringBody(data) + return targetFunction.apply(target, args) + } + } + + protected getSetRequestHeader(target: T) { + const targetFunction = Reflect.get(target, 'setRequestHeader') + return (...args: any[]) => { + if (!this.item.requestHeader) { + this.item.requestHeader = {} + } + // @ts-ignore + this.item.requestHeader[args[0]] = args[1] + return targetFunction.apply(target, args) + } + } + + protected setOnReadyStateChange(target: T, key: string, orscFunction: (args: any[]) => any) { + return Reflect.set(target, key, (...args: any[]) => { + this.onReadyStateChange() + orscFunction?.apply(target, args) + }) + } + + protected setOnAbort(target: T, key: string, oaFunction: (args: any[]) => any) { + return Reflect.set(target, key, (...args: any[]) => { + this.onAbort() + oaFunction.apply(target, args) + }) + } + + protected setOnTimeout(target: T, key: string, otFunction: (args: any[]) => any) { + return Reflect.set(target, key, (...args: any[]) => { + this.onTimeout() + otFunction.apply(target, args) + }) + } + + /** + * Update item's properties according to readyState. + */ + protected updateItemByReadyState() { + switch (this.XMLReq.readyState) { + case RequestState.UNSENT: + case RequestState.OPENED: + this.item.status = RequestState.UNSENT + this.item.statusText = 'Pending' + if (!this.item.startTime) { + this.item.startTime = performance.now() + } + break + case RequestState.HEADERS_RECEIVED: + this.item.status = this.XMLReq.status + this.item.statusText = 'Loading' + this.item.header = {} + // eslint-disable-next-line no-case-declarations + const header = this.XMLReq.getAllResponseHeaders() || '', + headerArr = header.split('\n') + // extract plain text to key-value format + for (let i = 0; i < headerArr.length; i++) { + const line = headerArr[i] + if (!line) { + continue + } + const arr = line.split(': ') + const key = arr[0] + this.item.header[key] = arr.slice(1).join(': ') + } + break + case RequestState.LOADING: + this.item.status = this.XMLReq.status + this.item.statusText = 'Loading' + if (!!this.XMLReq.response && this.XMLReq.response.length) { + this.item.responseSize = this.XMLReq.response.length + this.item.responseSizeText = formatByteSize(this.item.responseSize) + } + break + case RequestState.DONE: + // `XMLReq.abort()` will change `status` from 200 to 0, so use previous value in this case + this.item.status = this.XMLReq.status || this.item.status || 0 + // show status code when request completed + this.item.statusText = String(this.item.status) + this.item.endTime = performance.now() + this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime) + this.item.response = this.XMLReq.response + + if (!!this.XMLReq.response && this.XMLReq.response.length) { + this.item.responseSize = this.XMLReq.response.length + this.item.responseSizeText = formatByteSize(this.item.responseSize) + } + break + default: + this.item.status = this.XMLReq.status + this.item.statusText = 'Unknown' + break + } + } +} + +export default class XHRProxy { + public static create( + ignoredHeaders: boolean | string[], + sanitize: (data: RequestResponseData) => RequestResponseData, + sendMessage: (data: any) => void, + isServiceUrl: (url: string) => boolean, + tokenUrlMatcher?: (url: string) => boolean, + ) { + return new Proxy(XMLHttpRequest, { + construct(original: any) { + const XMLReq = new original() + return new Proxy( + XMLReq, + new XHRProxyHandler( + XMLReq as XMLHttpRequest, + ignoredHeaders, + sanitize, + sendMessage, + isServiceUrl, + tokenUrlMatcher, + ), + ) + }, + }) + } +} diff --git a/tracker/tracker-reactnative/src/index.tsx b/tracker/tracker-reactnative/src/index.tsx index 580f40572..7b7f4bdff 100644 --- a/tracker/tracker-reactnative/src/index.tsx +++ b/tracker/tracker-reactnative/src/index.tsx @@ -7,6 +7,8 @@ import { TextInput, } from 'react-native'; import type { ViewProps, TextInputProps } from 'react-native'; +import network from './network' +import type { Options as NetworkOptions } from './network' const { ORTrackerConnector } = NativeModules; @@ -68,6 +70,14 @@ interface IORTrackerConnector { event: (name: string, payload?: string) => void; setUserID: (userID: string) => void; userAnonymousID: (userID: string) => void; + networkRequest: ( + url: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + requestJSON: string, + responseJSON: string, + status: number, + duration: number + ) => void; } const emptyShell = { @@ -77,13 +87,19 @@ const emptyShell = { event: () => null, setUserID: () => null, userAnonymousID: () => null, + networkRequest: () => null, }; +const patchNetwork = (ctx = global, isServiceUrl = () => false, opts: Partial) => { + network(ctx, ORTrackerConnector.networkRequest, isServiceUrl, opts) +} + export default { tracker: Platform.OS === 'ios' ? (ORTrackerConnector as IORTrackerConnector) : emptyShell, + patchNetwork: Platform.OS === 'ios' ? patchNetwork : () => null, ORTouchTrackingView: Platform.OS === 'ios' ? RntrackerTouchTrackingView : View, ORSanitizedView: Platform.OS === 'ios' ? RntrackerSanitizedView : View, diff --git a/tracker/tracker-reactnative/src/network.ts b/tracker/tracker-reactnative/src/network.ts new file mode 100644 index 000000000..92b9f17a4 --- /dev/null +++ b/tracker/tracker-reactnative/src/network.ts @@ -0,0 +1,71 @@ +import setProxy from './Network/index' + +interface RequestData { + body: string | null + headers: Record +} + +interface ResponseData { + body: any + headers: Record +} + +export interface RequestResponseData { + readonly status: number + readonly method: string + url: string + request: RequestData + response: ResponseData +} + +type Sanitizer = (data: RequestResponseData) => RequestResponseData + +export interface Options { + ignoreHeaders: Array | boolean + capturePayload: boolean + captureInIframes: boolean + sanitizer?: Sanitizer + tokenUrlMatcher?: (url: string) => boolean +} + +export default function (context = global, sendMessage: (args: any[]) => void, isServiceUrl: (url: string) => boolean, opts: Partial = {}) { + const options: Options = Object.assign( + { + failuresOnly: false, + ignoreHeaders: ['cookie', 'set-cookie', 'authorization'], + capturePayload: false, + captureInIframes: true, + axiosInstances: undefined, + useProxy: true, + }, + opts, + ) + + function sanitize(reqResInfo: RequestResponseData) { + if (!options.capturePayload) { + // @ts-ignore + delete reqResInfo.request.body + delete reqResInfo.response.body + } + if (options.sanitizer) { + const resBody = reqResInfo.response.body + if (typeof resBody === 'string') { + // Parse response in order to have handy view in sanitization function + try { + reqResInfo.response.body = JSON.parse(resBody) + } catch {} + } + return options.sanitizer(reqResInfo) + } + return reqResInfo + } + + return setProxy( + context, + options.ignoreHeaders, + sanitize, + sendMessage, + (url) => isServiceUrl(url), + options.tokenUrlMatcher, + ) +}