一、前言
咱们每个人可能都会遇到这样的问题:即咱们的代码在本地测试时没有问题,然而一上线运行,就会遇到各种奇奇怪怪的线上 Bug。因为本地测试场景并不能全面笼罩,对于这种线上的 Bug,最无效的伎俩就是搭建线上监控零碎,而后再进行批改。所以,不论是如许小的零碎,线上谬误与性能监控是必须具备的能力。
通常,从头搭建和迭代一个监控零碎的老本是十分高的,如果你也有线上谬误和性能的监控需要,然而公司外部又没有现成的监控零碎,那我的倡议是间接用 Sentry。Sentry 译为哨兵,是一个可能实时监控生产环境上的监控零碎,一旦线上版本产生异样回立即会把报错的路由门路、谬误所在文件等详细信息告诉给相干人员,而后开发人员就能够利用错误信息的堆栈跟踪疾速定位到须要解决的问题。Sentry 提供了一个演示 Demo,你能够间接关上它,体验下它有哪些具体的性能。
而且 Sentry 的代码是开源的,它既反对开发者本人搭建,也反对付费间接应用。如果想本人搭建的话,Sentry 后端服务是基于 Python 和 ClickHouse 创立的,须要本人应用物理机进行搭建。不过,对于小团队来说,间接应用付费服务即可,能够省下麻烦的保护老本。
二、根本信息收集
首先,咱们要明确一点,解决线上问题和解决本地问题的思路是不一样的,即通过复现谬误门路,而后定位问题并解决问题。当然,在定位问题的过程中也能够应用调试工具,比方 Flipper,它有打日志、打断点等性能。
不过,在解决线上问题时,咱们并不能重复尝试和应用调试工具,此时就须要相似 Sentry 这样的线上监控工具来帮忙咱们排查问题。如果咱们对 Sentry 线上监控 SDK 的比拟理解的话,你会发现它次要收集了三类线上数据:
- 设施信息;
- 报错日志;
- 利用性能数据。
所以接下来,咱们要先一起实现一个繁难监控 SDK,把这些信息都收集下来,这样你就可能明确 Sentry 线上监控 SDK 的底层原理了。当然,以上信息的收集必须恪守网信办的《网络数据安全管理条例(征求意见稿)》,像设施惟一标示 IMEI、用户地理位置、运营商编号这些信息,咱们是不能收集的,如果须要收集,是须要通过用户受权批准的。
你可能会问,不能收集设施惟一标示 IMEI,那咱们怎么晓得用户是谁啊?代替 IMEI 计划就是 UUID。UUID 的全称是 Universally Unique Identifier,翻译过去就是通用惟一识别码,它是通过一个随机算法生成的 128 位的标识。生成两个反复 UUID 概率靠近零,能够忽略不计,因而咱们能够应用 UUID 代替与用户设施绑定的 IMEI 作为惟一标示符,该办法也是业内的通用计划之一。
为了收集设施惟一 UUID,咱们能够应用 UUID 算法配合 AsyncStorage 或 MMKV 生成一个用户 ID,代码如下:
import uuid from 'react-native-uuid';
import {MMKV} from 'react-native-mmkv'
// 用户惟一标示
let userId = ''
const storage = new MMKV()
const hasUserId = storage.contains('userId')
// 用户已经关上过 App
if(hasUserId) {userId = storage.get('userId')
} else {
// 用户第一次关上 App
userId = uuid.v4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
storage.set('userId', userId)
}
如上代码中的 react-native-uuid 是 UUID 算法的 React Native 版本。react-native-mmkv 是长久化键值存储工具,MMKV 的性能比 AsyncStorage 更好,所以我这里就用它代替了 AsyncStorage。
生成用户惟一标示 userId 的思路是这样的:每次开打 App 时,先应用 storage.contains(‘userId’) 判断一下在 MMKV 长久化键值存储核心是否存在 userId。如果 userId 的键值对不存在,那么该用户是第一次关上 App,这时应用 uuid.v4 算法生成一个 uuid 作为用户的惟一标示,并应用 userId 作为键名,调用 storage.get 办法将该键值对存在 MMKV 中。如果存在 userId 的键值对,那么该用户是就不是第一次关上 App 了,这时间接应用 userId 这个键名,将第一次关上 App 生成的用户惟一标示,从 MMKV 中读出来就能够了。
有了 userId 这个用户惟一标示后,后盾剖析收集上来的线上信息时,就能够把线上报错、性能等信息和某个具体的用户挂上钩了,比方你能够通过对 userId 字段进行去重,而后就能够确定影响的用户。
当然,作为一个线上运行的监控零碎,光有 userId、用户画像还是不够清晰,你还得晓得他设施信息,这样用户画像才更平面。在 React Native 中,咱们能够应用 react-native-device-info 插件来获取设施信息,示例代码如下:
import DeviceInfo from 'react-native-device-info';
//API 提供了获取的能力,但依据《网络数据安全管理条例(征求意见稿)》是不能上报的,所以举荐应用 uuid 代替。const androidIdPromise = DeviceInfo.getAndroidId()
// 将设施信息收集到一个 deviceInfo 对象中,对立上报。const deviceInfo = {}
deviceInfo.systemName = DeviceInfo.getSystemName();
deviceInfo.systemVersion = DeviceInfo.getSystemVersion();
deviceInfo.brand = getBrand();
deviceInfo.appName = DeviceInfo.getApplicationName();
deviceInfo.appVersion = DeviceInfo.getVersion();
三、一般数据收集
对于线上环境,利用的报错信息咱们是间接看不到的,要通过监控 SDK 收集上来之后能力看到。那监控 SDK 如何收集这些报错信息呢?上面提供三种计划:
- ErrorUtils.setGlobalHandler;
- PromiseRejectionTracking;
- Error Boundaries。
咱们先来看 ErrorUtils.setGlobalHandler,它是用来解决 JavaScript 的全局异样的。如果某个 JavaScript 函数报错,并且该报错没有被捕捉,该报错就会抛到全局中。代码如下:
function throwError(errorName){thow new Error(errorName)
}
try {throwError('该谬误会被 try catch 捕捉')
} catch(){}
throwError('该谬误没有捕捉,会抛到全局')
在这个示例中,第一个谬误是被 try catch 捕捉的谬误,因为开发者曾经对谬误进行了解决,谬误就不会再往外抛了,本地调试时也不会有红屏。第二个谬误,开发者并没有 try catch 解决,该谬误就会一层层往外抛,最终抛向全局作用域。
本地调试时,如果一个报错抛到了全局作用域,就会呈现红屏。本地调试的红屏其实是,React Native 框架在外部应用 ErrorUtils.setGlobalHandler 捕捉到全局谬误后,调用 LogBox 显示的红屏。红屏报错逻辑波及框架源码的两个文件,别离是 setUpErrorHandling.js 和 ExceptionsManager.js,上面是调用 LogBox 显示红屏的要害代码:
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {if (__DEV__) {const LogBox = require('../LogBox/LogBox');
LogBox.addException({
message: error.message,
name: error.name,
componentStack: error.componentStack,
stack: error.stack,
isFatal
});
}
});
从这段代码能够看出,没有被 try catch 住的报错,会触发 setGlobalHandler 的回调,在该回调中会判断,如果是 DEV 环境,那么就用 LogBox 组件把报错的 message、name、componentStack、stack、isFatal 等信息展现进去,这样一来就能够在本地报错时,看到红屏的报错信息了。
看到这儿,你可能会问:既然 React Native 框架在本地调试时应用的是 ErrorUtils.setGlobalHandler,那么是否能够把这段逻辑改改用于线上谬误监控呢?
这条思路很好。沿着这条思路想上来,咱们有两个计划能够实现线上全局错误信息的上报,一种是应用 patch-package 批改 React Native 源码,另一种应用 ErrorUtils.setGlobalHandler 重写回调函数。显然,重写回调函数比间接批改源码侵入性更小,更利于后续保护,因而我抉择了重写回调函数的形式,代码如下。
const defaultHandler = ErrorUtils.getGlobalHandler && ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
console.log(
`Global Error Handled: ${JSON.stringify(
{
isFatal,
errorName: error.name,
errorMessage: error.message,
componentStack: error.componentStack,
errorStack: error.stack,
},
null,
2,
)}`,
);
defaultHandler(error, isFatal);
});
在这段代码中,React Native 框架的代码会比我的代码先执行,所以它会先调用一次 ErrorUtils.setGlobalHandler 设置回调函数,而我的代码会在 React Native 框架代码执行之后再执行,并通过 ErrorUtils.getGlobalHandler 获取 React Native 框架设置的回调函数 defaultHandler。接着,我再次调用 ErrorUtils.setGlobalHandler 从新设置回调函数。在重置的回调函数中,我能够先解决本人的谬误上报逻辑,这里用的是 console.log 代替的,而后再调用 React Native 框架的 defaultHandler 解决红屏报错。
四、Promise 报错收集
对于一般的 JavaScript 谬误,咱们能够应用 try catch 捕捉,但 对于 promise 谬误,try catch 是捕捉不到的,须要用 promise.catch 来捕捉。因而,二者全局的捕捉机制也是不一样的。
React Native 提供了两种 Promise 捕捉机制,一种是由新架构的 Hermes 引擎提供的捕捉机制,另一种是老架构非 Hermes 引擎提供的捕捉机制。这两种捕捉机制,你都能够在 React Native 源码中找到,它波及 polyfillPromise.js、Promise.js、promiseRejectionTrackingOptions.js 三个文件,上面是要害代码。
const defualtRejectionTrackingOptions = {
allRejections: true,
onUnhandled: (id: string, error: Error) => {},
onHandled : (id: string) => {}}
if (global?.HermesInternal?.hasPromise?.()) {if (__DEV__) {
global.HermesInternal?.enablePromiseRejectionTracker?.(defualtRejectionTrackingOptions,);
}
} else {if (__DEV__) {require('promise/setimmediate/rejection-tracking').enable(defualtRejectionTrackingOptions,);
}
}
在下面这个示例中,咱们先申明了一个配置项 defualtRejectionTrackingOptions。这个配置项中最重要的就是 onUnhandled 回调函数,该回调函数是专门用来解决未被 catch 的 Promise 谬误的。
接着,再通过 HermesInternal.hasPromise 判断该 React Native 利用是否用的是 Hermes 引擎,如果返回 true 则为 Hermes 引擎,否则为其余引擎。如果是 Hermes 引擎,咱们就应用 Hermes 引擎提供的 enablePromiseRejectionTracker 办法来捕捉未被 catch 的 Promise 谬误,如果不是 Hermes 引擎,则应用第三方 promise 库中 rejection-tracking 文件裸露的 enable 办法来捕捉未被 catch 的 Promise 谬误。
以上,就是 React Native 外部解决 Promise 的逻辑。接着还须要将未被捕捉的 Promise 谬误进行上报。上报之前,须要调用上一次 Hermes 引擎提供的 enablePromiseRejectionTracker 办法,或者再调用一次 rejection-tracking 文件裸露的 enable 办法,将框架的默认解决逻辑笼罩。
const cusotomtRejectionTrackingOptions = {
allRejections: true,
onUnhandled: (id: string, error: Error) => {
// 上报谬误日志
console.log(
`Possible Unhandled Promise Rejection: ${JSON.stringify({
id,
errorMessage: error.message,
errorStack: error.stack,
},null,2)}`,
},
onHandled : (id: string) => {}}
if (global?.HermesInternal?.hasPromise?.()) {if (__DEV__) {
global.HermesInternal?.enablePromiseRejectionTracker?.(cusotomtRejectionTrackingOptions,);
}
} else {if (__DEV__) {require('promise/setimmediate/rejection-tracking').enable(cusotomtRejectionTrackingOptions,);
}
}
开发者自定义的未捕捉的 Promise 报错解决逻辑就是这样,和 React Native 框架外部的调用办法简直一样。惟一不同的是,开发者能够在 onUnhandled 和 onHandled 回调中自定义谬误的上报办法。在上述代码中,为了不便大家的了解,咱们应用 console 形式代替了谬误上报的逻辑。
五、组件报错收集
在 React/React Native 利用中,除了全局 JavaScript 报错和未捕捉的 Promise 报错以外,还有一类报错能够对立解决,就是 React/React Native 的 render 报错。在类组件中,render 报错指的是类的 render 办法执行报错;在函数组件中,render 报错指的就是函数自身执行报错了。
function FunctionComponent() {const [renderError, setRenderError] = useState(false)
if(renderError) throw Error('render 报错')
return <View></View>
}
function ClassComponent() {
state = {renderError: false}
render(){
return (
<View>
{this.state.renderError && <span></span>}
</View>
)
}
}
能够看到,第一个 FunctionComponent 示例是,当 renderError 状态由 false 变为 true 时,函数组件执行了到一半就会被 throw Error 报错打断。第二个 ClassComponent 示例是,当 this.state.renderError 状态由 false 变为 true 时,render 办法执行时发现了一个 React Native 中不存在的组件 span,整个渲染过程被中断。
相似这两种组件的 render 执行报错,在本地会抛红屏,在线上可能就是没有任何反馈或者白屏。那如何解决整个页面无响应或者白屏的问题呢?
React/React Native 也提供了相似 try catch 的办法,叫做 Error Boundaries。Error Boundaries 是专门用于捕捉组件 render 谬误的。
不过,React/React Native 只提供了类组件捕捉 render 谬误的办法,如果是函数组件,必须将其嵌套在类组件中能力捕捉其 render 谬误。业内通常的做法是将其封装成一个通用办法给其余组件应用,比方 Sentry 就提供了 ErrorBoundary 组件和 withErrorBoundary 办法来帮忙其余类组件或函数组件捕捉 render 谬误。比方:
class ErrorBoundary extends React.Component {constructor(props) {super(props);
this.state = {hasError: false};
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染可能显示降级后的 UI
return {hasError: true};
}
componentDidCatch(error, errorInfo) {
// 你同样能够将谬误日志上报给服务器
logErrorToMyService(error, errorInfo);
}
render() {if (this.state.hasError) {
// 你能够自定义降级后的 UI 并渲染
return <View>404 页面 </View>;
}
return this.props.children;
}
}
<ErrorBoundary>
<App/>
</ErrorBoundary>
这段代码中的 ErrorBoundary 是用于捕捉 App 组件 render 执行报错的组件。如果 App 组件 render 没有报错,那么会走 return this.props.children 的逻辑失常渲染;如果 App 组件 render 报错了,那么会触发 getDerivedStateFromError 回调,在 getDerivedStateFromError 回调中将管制是否有报错的开关状态 hasError 关上,并从新执行 render 渲染降级后的 404 页面,同时还会触发 componentDidCatch 回调。你能够在 componentDidCatch 回调中将组件的 render 谬误上报。
在这个示例中,我用 ErrorBoundary 包裹的是 App 组件,也就是通常意义上的根组件,只有页面中呈现任意组件的 render 谬误,就会渲染一个“404 页面”。实际上,你也能够应用 ErrorBoundary 包裹部分组件,当某个部分组件呈现谬误时,应用其余部分组件将其替换。
六、性能数据收集
绝对于谬误收集,性能收集的优先级会低一些,因为谬误影响的是操作问题,是影响业务运行的,而性能影响的是体验问题,看起来并不会如许的迫切。晚期的 Sentry 也是只收集谬误不收集性能的,但当初也开始器重性能收集了。Sentry 次要收集的性能包含:
- App 启动耗时;
- 页面跳转耗时;
- 申请耗时。
像 App 启动耗时、页面跳转耗时和申请耗时这些耗时类的统计原理,都是通过两个工夫点的距离计算出来的,即【耗时 = 完结工夫点 – 起始工夫点】。能够看到,总耗时等于完结工夫点减去开始工夫点的差值,开始工夫和完结工夫点都是通过 Date.now() 获取的以后零碎工夫,单位是 ms。
对于 App 启动耗时、页面跳转耗时和申请耗时的工夫点,我画了一张示意图:
![上传中 …]()
6.1 启动耗时
App 启动的开始工夫点是在 Native 组件的生命周期外面的,个别是 oncreate() 办法。例如,在 Android 上就是 Fragment 所在的 Activity 启动实现后的 onActivityCreated() 办法作为开始工夫点。App 启动的完结工夫点是在 React/React Native 利用的生命周期里,也就是组件挂载实现 componentDidMount() 办法作为完结的工夫点。
尽管 App 只有一个,但页面、申请有很多个。统计 App 启动耗时能够在 Native 根组件或 React 根组件的生命周期外面统计,只需统计一次就行。但你不可能在每个页面的开始挂载和完结挂载的生命周期回调外面增加统计,也不可能在每个申请开始之前和回来之后增加统计。
6.2 页面跳转耗时
如何统计 App 中所有的页面跳转耗时呢?如果你应用的是 React Navigation,那在每次页面跳转之前都会触发下达跳转命令。在下达跳转命令的时候会触发 unsafe_action 事件,你能够在 unsafe_action 事件的回调中增加页面跳转耗时的开始工夫点。在页面跳转实现后,页面的状态会产生扭转,此时会触发 state 扭转事件,此时再增加完结工夫点。上面是一段示例代码:
function App({navigation}) {useEffect(()=>{
let startTime = 0
navigation.addListener('__unsafe_action__', (e) => {startTime = Date.now()
});
navigation.addListener('state', (e) => {const totalTime = Date.now() - startTime
console.log(`totalTime:${totalTime}`)
});
},[])
return <></>
}
从代码中能够看到,咱们毋庸在每个组件的申明周期外面都增加回调,只用在 App 根组件挂载后,间接监听导航命令触发的 unsafe_action 和 state 事件就能够实现页面跳转耗时的统计。
当然下面的示例代码只是列举了原理,还有些边界状况没有思考到,如果你对其中细节感兴趣你能够查看一下 Sentry 的 ReactNavigation 局部的源码。
6.3 申请耗时
申请耗时通常统计的是从申请开始,到数据返回的整个链路所消耗的工夫。实现也很不便,即在申请的时候获取开始工夫,在响应后获取完结工夫,而后通过时间差即可失去申请耗时。示例代码如下:
let startTime = 0
const originalOpen = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype.open(function(...args){startTime = Date.now()
const xhr = this;
const originalOnready = xhr.prototype.onreadystatechange
xhr.prototype.onreadystatechange = function(...readyStateArgs) {if (xhr.readyState === 4) {const totalTime = Date.now() - startTime
console.log(`totalTime:${totalTime}`)
}
originalOnready(...readyStateArgs)
}
originalOpen.apply(xhr, args)
})
能够看到,因为 React Native 中的 fetch 或 axios 申请都是基于 XMLHttpRequest 包装的,所以要统计申请耗时,就要监听 XMLHttpRequest 的 open 事件,以及其实例 xhr 的 onreadystatechange 事件。
即在 open 事件中,记录申请开始的工夫点,在 onreadystatechange 事件触发时且 xhr.readyState 为胜利时记录申请的完结工夫点,再做一个减法即失去申请的耗时。