instabug-reactnative
Version:
React Native plugin for integrating the Instabug SDK
334 lines (292 loc) • 11.5 kB
text/typescript
import InstabugConstants from './InstabugConstants';
import { stringifyIfNotString, generateW3CHeader } from './InstabugUtils';
import { FeatureFlags } from '../utils/FeatureFlags';
export type ProgressCallback = (totalBytesSent: number, totalBytesExpectedToSend: number) => void;
export type NetworkDataCallback = (data: NetworkData) => void;
export interface NetworkData {
readonly id: string;
url: string;
method: string;
requestBody: string;
requestBodySize: number;
responseBody: string | null;
responseBodySize: number;
responseCode: number;
requestHeaders: Record<string, string>;
responseHeaders: Record<string, string>;
contentType: string;
errorDomain: string;
errorCode: number;
startTime: number;
duration: number;
gqlQueryName?: string;
serverErrorMessage: string;
requestContentType: string;
isW3cHeaderFound: boolean | null;
partialId: number | null;
networkStartTimeInSeconds: number | null;
w3cGeneratedHeader: string | null;
w3cCaughtHeader: string | null;
}
const XMLHttpRequest = global.XMLHttpRequest;
let originalXHROpen = XMLHttpRequest.prototype.open;
let originalXHRSend = XMLHttpRequest.prototype.send;
let originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
let onProgressCallback: ProgressCallback | null;
let onDoneCallback: NetworkDataCallback | null;
let isInterceptorEnabled = false;
let network: NetworkData;
const _reset = () => {
network = {
id: '',
url: '',
method: '',
requestBody: '',
requestBodySize: 0,
responseBody: '',
responseBodySize: 0,
responseCode: 0,
requestHeaders: {},
responseHeaders: {},
contentType: '',
errorDomain: '',
errorCode: 0,
startTime: 0,
duration: 0,
gqlQueryName: '',
serverErrorMessage: '',
requestContentType: '',
isW3cHeaderFound: null,
partialId: null,
networkStartTimeInSeconds: null,
w3cGeneratedHeader: null,
w3cCaughtHeader: null,
};
};
const getTraceparentHeader = async (networkData: NetworkData) => {
const [
isW3cExternalTraceIDEnabled,
isW3cExternalGeneratedHeaderEnabled,
isW3cCaughtHeaderEnabled,
] = await Promise.all([
FeatureFlags.isW3ExternalTraceID(),
FeatureFlags.isW3ExternalGeneratedHeader(),
FeatureFlags.isW3CaughtHeader(),
]);
return injectHeaders(networkData, {
isW3cExternalTraceIDEnabled,
isW3cExternalGeneratedHeaderEnabled,
isW3cCaughtHeaderEnabled,
});
};
export const injectHeaders = (
networkData: NetworkData,
featureFlags: {
isW3cExternalTraceIDEnabled: boolean;
isW3cExternalGeneratedHeaderEnabled: boolean;
isW3cCaughtHeaderEnabled: boolean;
},
) => {
const {
isW3cExternalTraceIDEnabled,
isW3cExternalGeneratedHeaderEnabled,
isW3cCaughtHeaderEnabled,
} = featureFlags;
if (!isW3cExternalTraceIDEnabled) {
return;
}
const isHeaderFound = networkData.requestHeaders.traceparent != null;
networkData.isW3cHeaderFound = isHeaderFound;
const injectionMethodology = isHeaderFound
? identifyCaughtHeader(networkData, isW3cCaughtHeaderEnabled)
: injectGeneratedData(networkData, isW3cExternalGeneratedHeaderEnabled);
return injectionMethodology;
};
const identifyCaughtHeader = (networkData: NetworkData, isW3cCaughtHeaderEnabled: boolean) => {
if (isW3cCaughtHeaderEnabled) {
networkData.w3cCaughtHeader = networkData.requestHeaders.traceparent;
return networkData.requestHeaders.traceparent;
}
return;
};
const injectGeneratedData = (
networkData: NetworkData,
isW3cExternalGeneratedHeaderEnabled: boolean,
) => {
const { timestampInSeconds, partialId, w3cHeader } = generateW3CHeader(networkData.startTime);
networkData.partialId = partialId;
networkData.networkStartTimeInSeconds = timestampInSeconds;
if (isW3cExternalGeneratedHeaderEnabled) {
networkData.w3cGeneratedHeader = w3cHeader;
return w3cHeader;
}
return;
};
export default {
setOnDoneCallback(callback: NetworkDataCallback) {
onDoneCallback = callback;
},
setOnProgressCallback(callback: ProgressCallback) {
onProgressCallback = callback;
},
enableInterception() {
// Prevents infinite calls to XMLHttpRequest.open when enabling interception multiple times
if (isInterceptorEnabled) {
return;
}
originalXHROpen = XMLHttpRequest.prototype.open;
originalXHRSend = XMLHttpRequest.prototype.send;
originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
// An error code that signifies an issue with the RN client.
const clientErrorCode = 9876;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
_reset();
network.url = url;
network.method = method;
originalXHROpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
// According to the HTTP RFC, headers are case-insensitive, so we convert
// them to lower-case to make accessing headers predictable.
// This avoid issues like failing to get the Content-Type header for a request
// because the header is set as 'Content-Type' instead of 'content-type'.
const key = header.toLowerCase();
network.requestHeaders[key] = stringifyIfNotString(value);
originalXHRSetRequestHeader.apply(this, [header, value]);
};
XMLHttpRequest.prototype.send = async function (data) {
const cloneNetwork = JSON.parse(JSON.stringify(network));
cloneNetwork.requestBody = data ? data : '';
if (typeof cloneNetwork.requestBody !== 'string') {
cloneNetwork.requestBody = JSON.stringify(cloneNetwork.requestBody);
}
if (this.addEventListener) {
this.addEventListener('readystatechange', async () => {
if (!isInterceptorEnabled) {
return;
}
if (this.readyState === this.HEADERS_RECEIVED) {
const contentTypeString = this.getResponseHeader('Content-Type');
if (contentTypeString) {
cloneNetwork.contentType = contentTypeString.split(';')[0];
}
const responseBodySizeString = this.getResponseHeader('Content-Length');
if (responseBodySizeString) {
const responseBodySizeNumber = Number(responseBodySizeString);
if (!isNaN(responseBodySizeNumber)) {
cloneNetwork.responseBodySize = responseBodySizeNumber;
}
}
if (this.getAllResponseHeaders()) {
const responseHeaders = this.getAllResponseHeaders().split('\r\n');
const responseHeadersDictionary: Record<string, string> = {};
responseHeaders.forEach((element) => {
const key = element.split(/:(.+)/)[0];
const value = element.split(/:(.+)/)[1];
responseHeadersDictionary[key] = value;
});
cloneNetwork.responseHeaders = responseHeadersDictionary;
}
if (cloneNetwork.requestHeaders['content-type']) {
cloneNetwork.requestContentType =
cloneNetwork.requestHeaders['content-type'].split(';')[0];
}
}
if (this.readyState === this.DONE) {
cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
if (this.status == null) {
cloneNetwork.responseCode = 0;
} else {
cloneNetwork.responseCode = this.status;
}
// @ts-ignore
if (this._hasError) {
cloneNetwork.errorCode = clientErrorCode;
cloneNetwork.errorDomain = 'ClientError';
// @ts-ignore
const _response = this._response;
cloneNetwork.requestBody =
typeof _response === 'string' ? _response : JSON.stringify(_response);
cloneNetwork.responseBody = '';
// Detect a more descriptive error message.
if (typeof _response === 'string' && _response.length > 0) {
cloneNetwork.errorDomain = _response;
}
// @ts-ignore
} else if (this._timedOut) {
cloneNetwork.errorCode = clientErrorCode;
cloneNetwork.errorDomain = 'TimeOutError';
}
if (this.response) {
if (this.responseType === 'blob') {
const responseText = await new Response(this.response).text();
cloneNetwork.responseBody = responseText;
} else if (['text', '', 'json'].includes(this.responseType)) {
cloneNetwork.responseBody = JSON.stringify(this.response);
}
} else {
cloneNetwork.responseBody = '';
cloneNetwork.contentType = 'text/plain';
}
cloneNetwork.requestBodySize = cloneNetwork.requestBody.length;
if (cloneNetwork.responseBodySize === 0 && cloneNetwork.responseBody) {
cloneNetwork.responseBodySize = cloneNetwork.responseBody.length;
}
if (cloneNetwork.requestHeaders[InstabugConstants.GRAPHQL_HEADER]) {
cloneNetwork.gqlQueryName =
cloneNetwork.requestHeaders[InstabugConstants.GRAPHQL_HEADER];
delete cloneNetwork.requestHeaders[InstabugConstants.GRAPHQL_HEADER];
if (cloneNetwork.gqlQueryName === 'null') {
cloneNetwork.gqlQueryName = '';
}
if (cloneNetwork.responseBody) {
const responseObj = JSON.parse(cloneNetwork.responseBody);
if (responseObj.errors) {
cloneNetwork.serverErrorMessage = 'GraphQLError';
} else {
cloneNetwork.serverErrorMessage = '';
}
}
} else {
delete cloneNetwork.gqlQueryName;
}
if (onDoneCallback) {
onDoneCallback(cloneNetwork);
}
}
});
const downloadUploadProgressCallback = (event: ProgressEvent) => {
if (!isInterceptorEnabled) {
return;
}
// check if will be able to compute progress
if (event.lengthComputable && onProgressCallback) {
const totalBytesSent = event.loaded;
const totalBytesExpectedToSend = event.total - event.loaded;
onProgressCallback(totalBytesSent, totalBytesExpectedToSend);
}
};
this.addEventListener('progress', downloadUploadProgressCallback);
this.upload.addEventListener('progress', downloadUploadProgressCallback);
}
cloneNetwork.startTime = Date.now();
const traceparent = await getTraceparentHeader(cloneNetwork);
if (traceparent) {
this.setRequestHeader('Traceparent', traceparent);
}
if (this.readyState === this.UNSENT) {
return; // Prevent sending the request if not opened
}
originalXHRSend.apply(this, [data]);
};
isInterceptorEnabled = true;
},
disableInterception() {
isInterceptorEnabled = false;
XMLHttpRequest.prototype.send = originalXHRSend;
XMLHttpRequest.prototype.open = originalXHROpen;
XMLHttpRequest.prototype.setRequestHeader = originalXHRSetRequestHeader;
onDoneCallback = null;
onProgressCallback = null;
},
};