feat(ios): network plugin for react native connector, tbt

This commit is contained in:
nick-delirium 2024-01-05 10:33:51 +01:00
parent 50c1961105
commit 799978cd27
13 changed files with 1020 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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 (
<Openreplay.ORTouchTrackingView style={styles.container}>
<View style={styles.container}>
@ -48,6 +59,9 @@ export default function App() {
<TouchableOpacity onPress={event}>
<Text>event</Text>
</TouchableOpacity>
<TouchableOpacity onPress={apiTest}>
<Text>Request</Text>
</TouchableOpacity>
<Openreplay.ORTrackedInput
style={styles.input}
onChangeText={onChangeNumber}

View file

@ -8,5 +8,6 @@ RCT_EXTERN_METHOD(setMetadata:(NSString *)key value:(NSString *)value)
RCT_EXTERN_METHOD(event:(NSString *)name object:(NSString *)object)
RCT_EXTERN_METHOD(setUserID:(NSString *)userID)
RCT_EXTERN_METHOD(userAnonymousID:(NSString *)userID)
RCT_EXTERN_METHOD(networkRequest:(NSString *)url method:(NSString *)method requestJSON:(NSString *)requestJSON responseJSON:(NSString *)responseJSON status:(nonnull NSNumber *)status duration:(nonnull NSNumber *)duration)
@end

View file

@ -64,4 +64,9 @@ public class ORTrackerConnector: NSObject {
open func userAnonymousID(_ userID: String) {
Openreplay.shared.userAnonymousID(userID)
}
@objc(networkRequest:method:requestJSON:responseJSON:status:duration:)
open func networkRequest(_ url: String, method: String, requestJSON: String, responseJSON: String, status: NSNumber, duration: NSNumber) {
Openreplay.shared.networkRequest(url: url, method: method, requestJSON: requestJSON, responseJSON: responseJSON, status: Int(truncating: status), duration: UInt64(truncating: duration))
}
}

View file

@ -0,0 +1,297 @@
// @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 { formatByteSize, genStringBody, getStringResponseByType, getURL } from './utils'
import { RequestResponseData } from './types'
export class ResponseProxyHandler<T extends Response> implements ProxyHandler<T> {
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 = <any>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 = <ReturnType<typeof _getReader>>_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 (<ReturnType<typeof _read>>_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<T extends typeof fetch> implements ProxyHandler<T> {
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 <ReturnType<T>>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 (<ReturnType<T>>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 = <NetworkMessage['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 <Headers>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,
),
)
}
}

View file

@ -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,
)
}
}

View file

@ -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<string, string> = {};
Object.entries(this.requestHeader).forEach(([key, value]) => {
if (this.isHeaderIgnored(key)) return;
// @ts-ignore
reqHs[key] = value;
});
const resHs: Record<string, string> = {};
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;
}
}
}

View file

@ -0,0 +1,15 @@
export interface RequestResponseData {
readonly status: number
readonly method: string
url: string
request: {
body: string | null
headers: Record<string, string>
}
response: {
body: string | null
headers: Record<string, string>
}
}
// we only support sanitizing for json/string data because how you're gonna sanitize binary data?

View file

@ -0,0 +1,207 @@
// @ts-nocheck
export const genResponseByType = (
responseType: XMLHttpRequest['responseType'],
response: any,
): string | Record<string, any> => {
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 <FormData | URLSearchParams>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 = <any>body
} else {
result = `can't parse body ${typeof body}`
}
return result
}
export const genGetDataByUrl = (url: string, getData: Record<string, any> = {}) => {
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 <FormData | URLSearchParams>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 = <any>body
} else {
result = `can't parse body ${typeof body}`
}
return result
}
export function isPureObject(input: any): input is Record<any, any> {
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)
}
}

View file

@ -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<T extends XMLHttpRequest> implements ProxyHandler<T> {
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,
),
)
},
})
}
}

View file

@ -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<NetworkOptions>) => {
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,

View file

@ -0,0 +1,71 @@
import setProxy from './Network/index'
interface RequestData {
body: string | null
headers: Record<string, string>
}
interface ResponseData {
body: any
headers: Record<string, string>
}
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<string> | 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<Options> = {}) {
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,
)
}