背景介绍
因业务发展的需要,我们在多个的小程序平台都有对应的投放,如微信小程序、百度智能小程序、支付宝小程序、字节小程序、360小程序等。在开发迭代过程中,由于平台、系统、机型和版本的兼容等问题,小程序出现线上问题时排查起来相对困难;
思考:为什么小程序启动的那么慢?线上白屏加载不出来数据到底是怎么回事?线上代码质量如何,是否有bug?线上出现问题,如何快速发现并解决?用户到底操作了什么?投放了这么多平台小程序,各平台效果究竟怎么样?当前小程序是否有可优化空间?
其实上面这些问题都可以通过日志文件分析得到解答,各个小程序后台也有采集一部分日志信息,比如微信后台有采集js异常日志、接口异常日志,但接口异常日志只有状态码层面的信息,脚本异常日志中缺乏当前异常发生时的页面路径信息、系统信息、网络状态、用户行为轨迹等信息的记录,因此排查起来还是相对困难的。对于小程序性能数据的采集,各平台之间也没有一个统一的标准。 所以我们需要一种兼容多平台的小程序日志采集方案。
采集思路
采集小程序的信息,首先需要先了解下小程序的基础架构设计。小程序实质就是 hybrid,但是是受限的。小程序的渲染层和逻辑层分别由两个线程管理:
渲染层的界面使用 WebView 进行渲染;
逻辑层采用 JSCore 或者 V8 等JS引擎 来运行 JavaScript 代码。
这两个线程间的通信经由小程序 Native 侧中转,逻辑层发送网络请求也经由 Native 侧转发。对于平台方而言,这种设计极大增加了平台对应用的控制,也减少了各种风险。
采集SDK当然也是在逻辑层中,因此能监听到逻辑层的交互响应。主流小程序JS逻辑层的开发主要依赖以下三个部分:
App:每个小程序都需要在 app.js 中调用 App 方法注册小程序实例,绑定生命周期回调函数、错误监听和页面不存在监听函数等。整个小程序只有一个 App 实例,是全部页面共享的。
Page:对于小程序中的每个页面,都需要在页面对应的 js 文件中进行注册,指定页面的初始数据、生命周期回调、事件处理函数等。简单的页面可以使用 Page() 进行构造。
wx:小程序开发框架提供丰富的微信原生 API,可以方便的调起微信提供的能力,如获取用户信息,本地存储,支付功能等。
所以想要有效的监控小程序的运行状态,就需要对这三个模块进行有效拦截处理。在小程序中App、Page、wx模块宏都是暴露在全局的,如果要进行拦截,直接重写全局中的变量即可。
整体方案
SDK的使用方涵盖小程序原生开发、使用小程序框架开发两种:
原生开发我们提供单文件的使用形式;
对于使用框架开发的,我们支持通过npm包形式使用。
SDK主要有两大功能:数据采集和上报服务。SDK 的主要作用是收集小程序产生的日志。采集日志可以分为 3 类,异常日志、正常日志、性能日志,每次日志上报时还会上报 1 类通用的信息:
日志类型 | 二级分类 | 说明 |
异常日志 | JS错误、接口异常、资源下载异常 | 监控关键,需实时上报 |
正常日志 | 用户行为、log信息、路由信息 | 日志量大,由用户主动上报 |
性能日志 | 首屏时间、页面渲染耗时、PV/UV | 上报一次,同时统计PV/UV |
通用信息 | 系统信息、用户信息、网络状态、场景值 | 其他 3 类日志附加的通用信息 |
我们的目标小程序涵盖了当前主流小程序,如微信、百度、头条、支付宝、360等。
公共日志采集
通过相关的API获取环境信息,主要包括以下几类信息:
wx.getUserInfo() 获取用户信息获取(需要授权,SDK尝试获取,没有的话为空)
wx.getSystemInfoSync() 获取系统信息(获取一次后保存到内存中)
wx.getNetworkType() 获取当前网络状态获取(每次获取)
App.onLaunch() 获取场景信息获取(获取一次后保存到内存中)
基础信息的API各平台小程序实现接口基本一致。主要区别在于模块宏的定义不同,比如微信小程序是wx,百度小程序是swan,头条小程序是tt。在SDK初始化的时候,通过判断模块宏是否存在确定当前所处的小程序环境并存储在上下文变量中,具体使用的时候通过上下文调用API。
public get context() {
if (this._context) return this._context;
if (typeof wx !== 'undefined') { this._context = wx; }
if (typeof swan !== 'undefined') { this._context = swan; }
if (typeof tt !== 'undefined') { this._context = tt; }
if (typeof my !== 'undefined') { this._context = my; }
if (typeof qq !== 'undefined') { this._context = qq; }
if (typeof qh !== 'undefined') { this._context = qh; }
return this._context;
}
// 使用示例 用户信息获取:ctx.getUserInfo()
主流小程序:在当前页面路径信息获取上,主要通过 getCurrentPage() 获取,但这个API需要在小程序加载完成后才可调用,但可通过App.onLaunch的回调中获取到首页路径信息;
360小程序比较特殊,需要通过 $router.history 获取。
所以这里提供了一个获取当前页面路径的方法,在方法内部磨平了各平台小程序的差异。
public get currentPage() {
if (this.appName === 'qh') { // 360小程序兼容
const { path } = $router.history.current;
return path;
}
let pages = getCurrentPages();
if (pages.length > 0) {
return pages[pages.length - 1].route;
} else {
return this._indexPage;
}
}
异常日志采集
由于小程序的特殊通信机制,接口请求、资源下载都是通过小程序载体Native转发执行的。因此要采集这部分的异常,可以通过拦截 wx.request、wx.download 等相关API实现。
关于接口异常信息的上报,主要涉及两个时机:在 wx.request 的success成功回调中,如果响应的状态码 >= 400,说明网络请求发送出去了,但业务响应非正常状态,作为一次异常上报;如果直接走到 wx.request 的 fail 失败回调,说明网络请求异常了也进行一次异常上报;
资源异常主要通过拦截 downloadFile 文件下载API,在下载失败的时候上报资源异常日志。
下面我们重点介绍下关于脚本异常的日志采集。
JS常见的错误类型有以下几种,其中类型错误、引用错误、未捕获的Promise错误发生频次较高;其次是越界错误和URI不正确错误。
其中前5中错误可以通过监听小程序App.onError采集到,Promise异常可通过App.onUnhandledRejection API采集,但这个API各平台的支持度是不一致的,具体如下:
App.onError可以采集到小程序中发生的脚本错误、API调用报错;
App.onUnhandleRejection各平台支持度不一致,微信在基础库2.10以上才支持,QQ部分支持,百度不支持;
App.onPageNotFoung在页面路由不存在时会触发;
App.onMemoryWarning在内存不足时触发告警;
通过对异常堆栈的跟踪,查看源码可以看到微信通过try...catch我们的业务代码外层进行了包裹,出现异常的时候通过 console.error 进行了打印输出,因此在开发过程中,可以看到当出现异常的时候,开发者工具控制台会有异常信息的打印。
因此我们可以拦截 console.error,这种拦截可采集到的异常包括下面几种:
脚本异常
API调用异常
Promise异常
自定义上报
在具体的实现过程中,我们发现不同平台的具体实现是不一样的:
微信、头条小程序拦截到异常后,在Error对象上增加了一些包装信息后调用console.error;
百度小程序将堆栈信息都转换成字符串后再调用console.error;
因此我们在拦截console.error后需要对拦截到的信息二次处理,磨平平台差异。
具体方案就是通过正则匹配:
const ERROR_TYPES_REG = /(((Eval|Reference|Range|Internal|Type|Syntax)Error)|promise)
确定当前脚本异常类型,格式化信息后将堆栈信息和错误内容分开上报,最终上报的脚本日志格式如下所示,包含错误类型、错误内容、堆栈信息。
"exceptions": [{
"errType": "MiniProgramError",
"content": "app.checkAuthorize1 is not a function",
"message": "TypeError: app.checkAuthorize1 is not a function",
"stacktrace": "MiniProgramErroranonymous> (httpe.p.__callPageLifeTime__ (h"
}]
性能日志采集
通过官方提供API获取数据并采集上报,微信提供 wx.getPerformance() API,可以获取到小程序启动耗时、页面首次渲染耗时、注入脚本耗时;但其他平台小程序没有提供相关的接口。
用户点击小程序后首先会去下载小程序资源包,启动后会先解析app.json文件,注册App(),然后执行APP的生命周期;然后会开始加载页面,解析页面json文件,渲染.wxml文件,执行逻辑层js文件并调用页面生命周期。APP和页面的生命周期函数是性能日志采集的关键点。
生命周期钩子 | 级别 | 说明 | 主流小程序框架 | 360小程序框架 |
onLaunch | App级 | 监听小程序初始化 | 支持 | onLoad |
onShow | App级 | 监听小程序启动或切前台 | 支持 | 支持 |
onLoad | Page级 | 监听页面加载 | 支持 | 支持 |
onShow | Page级 | 监听页面显示 | 支持 | 支持 |
onReady | Page级 | 监听页面初次渲染完成 | 支持 | 支持 |
onHide | Page级 | 监听页面隐藏 | 支持 | 支持 |
我们对各平台小程序生命周期相关API进行对比,发现主流小程序框架的实现基本一致,360小程序比较特殊,它是基于Vue框架进行的二次封装,但大部分API还是一致的,只是在360小程序中,小程序初始化完成的钩子叫onLoad,需要做特殊处理。
因此性能日志采集可通过拦截小程序App、Page的相应生命周期并打点实现。性能日志的采集方案上,我们主要划分为应用级别性能和页面级别性能。
应用级别的性能:我们定义了首屏时间,为进入小程序后首个页面的渲染完成时间 - SDK的初始化时间。
由于小程序入口比较多,有从分享卡片进入的、有点击图标进入的、有搜索进入的,不同方式进入小程序展示的首页会不一样,因此在每条日志我们都会携带当前页面入口路径。
页面级别性能主要包含:
页面加载耗时:页面加载耗时有两种情况,首页页面加载耗时 = 页面加载完成时间Page.onLoad - 小程序启动完成时间App.onShow;其他页面加载耗时时间 = 当前页面的加载完成时间Page.onLoad - 上一个页面的关闭时间PrePage.onHide;
页面显示耗时:当前页面的显示完成时间Page.onShow - 当前页面的加载完成时间Page.onLoad
页面渲染耗时:当前页面渲染完成时间Page.onReady - 当前页面显示时间Page.onShow
private interceptPage = (): void => {
let self = this;
let isTaro = (typeof (process) !== 'undefined' && typeof (process.env) !== 'undefined' && typeof (process.env.TARO_ENV) !== 'undefined') ? true : false;
const primaryPage = Page;
if (isTaro) {
let primaryComponent = Component;
Component = (obj: any) => {
PAGE_LIFE_CYCLE.forEach(name => {
if (typeof obj.methods[name] === 'function') {
const primaryHookFn = obj.methods[name];
obj.methods[name] = function (info: any) {
return self.rewritePageLifeCycle(name, this, primaryHookFn, info);
}
}
});
primaryComponent && primaryComponent.call(this, obj);
}
}
Page = (obj: any) => {
PAGE_LIFE_CYCLE.forEach(name => {
const primaryHookFn = obj[name];
obj[name] = function (info: any) {
return self.rewritePageLifeCycle(name, this, primaryHookFn, info);
}
});
primaryPage && primaryPage.call(this, obj);
}
}
行为轨迹采集
一些较隐蔽的错误如果只有错误栈信息,排查起来会比较难,如果有用户操作的路径,在排查时就方便多了。行为轨迹日志的采集上,我们会采集APP函数堆栈、Page函数堆栈、HTTP请求堆栈,在异常发生的时候上报最近10条行为轨迹日志辅助用户定位问题。
SDK特性
多端、多框架适配
轻量且支持多种模块化规范
SDK采用Rollup打包,压缩后体积 49KB,支持cmj、es6模块化规范。
1. 通过npm形式使用
import * as mpMonitor from 'mp-monitor';
mpMonitor.init({
projectId: '', // 项目标识
url: ''
});
2. 通过单文件形式使用
const mpMonitor = require('./utils/mp-monitor');
mpMonitor.init({
projectId: '', // 项目标识
url: ''
});
业务方可通过 console.error(\'自定义上报内容\')
业务实践
1. SDK目前已完成集团北斗前端监控平台对接,支持微信、百度、头条、支付宝、QQ、360等多端监控;
2. 截止2021.10.15,已接入小程序13个,覆盖5个业务线
3. SDK上报模型和Sentry保持一致,自行搭建Sentry服务的业务方可直接使用SDK,配置上报url即可;
4. 平台接入效果展示
开发者可通过首页图表观察到当前小程序运行状态
通过性能图表上可以考虑当前小程序是否有优化空间,如果开启分包加载优化,当前首屏性能曲线图应该会有明显的下降
通过页面url统计到的pvuv数据观察,在优化业务代码时可考虑将pv为0的页面做移除处理
页面加载瀑布图,可直观的看到小程序页面的一个加载过程
行为轨迹展示,可以帮助开发者更快的复现线上问题,提升问题解决效率
总 结
当前小程序日志采集SDK在内部抹平了多平台的差异,统一的小程序性能数据采集指标。上报异常发生时的页面路径信息、系统信息、网络状态、用户行为轨迹等相关信息,帮助开发者更快速的定位并解决线上问题。
我们会继续优化细节,完善功能,也欢迎感兴趣的同学来一起交流。
项目地址:https://github.com/wuba/mp-monitor
作者:
刘敏,ABG资深前端开发工程师,目前专注于小程序监控、微前端方向。