性能监控SDK
Background
一开始也没想要做监控,当时其实是接入了阿里的 arms
,但是接入一段时间后,觉得用起来不舒服,不知道是不是使用姿势不对,而且还收费,因此才有了自己实现一套监控的想法。
功能不需要很多,毕竟产品也没有很多人使用(数量只有百人而已),目的主要是监控异常及时发现问题而非性能优化,并不是说性能不重要,只是当下快速迭代及时发现异常比较重要。
What
基于这样的想法,要实现的功能就比较明确
- 用户分析
- 行为分析
- JS Error
- API Error
- 链路分析
- 告警
用户分析
回答什么人、什么地方、什么时间
- Who: userid
- where: ua, os, geo
- when: time
- bizData:业务数据
interface IUserData {
ua?: string
uav?: string
os: string
osVersion: string
sr: string
sdkVersion?: string // 小程序基础库
}
行为分析
做了什么事情 即 behavior
- page url:当前是哪个页面
- click:点击了哪些页面元素
interface IPageData {
href: string
}
interface IEventData {
type: 'click',
path: string
}
interface IBehaviorData {
type: 'page' | 'event'
data: IPageData | IEventData
}
JS Error
分为三类
- JS Error
- Resource Error
- Custom Error
enum ERROR_CATE {
JS_ERROR = "Js Error",
CUSTOM_ERROR = "Custom Error",
RESOURCE_ERROR = "Resource Error",
}
enum LOG_LEVEL {
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}
export interface IErrorData {
cate: ERROR_CATE,
message: string
stack?: string
src?: string // resourceError
nodename?: string
level?: LOG_LEVEL // customError
}
API Error
主要是接口请求异常 以及正常接口的上报
export interface IHttpData {
method: string,
status: string,
requestTime: number, // 发起请求的时间
url: string,
pageUrl: string, // 当前的页面地址
time: number, // 耗时
requestParams?: string,
message: string
traceId?: string // 后端的traceId
}
链路分析
根据以上日志,可以串联一个用户在应用内所有的行为轨迹
告警
规则定制:
- JS/API 分开配置
- 通用规则
- 某个错误,某个接口,单独定制
- 忽略某些消息
时效性:每隔 N
分钟看一下最新数据
阈值:根据规则匹配,发现异常大于 M
次
How
- Step 1 采集数据
- Step 2 分析数据
- Step 3 平台显示
这里重点看下 Step 1 收集数据 的设计,数据分析其实就是 mongodb 的聚合查询,不再赘述。
SDK 的设计如上,有2个类 Collector/Clue
Clue
对应前面所说的需要统计的数据,目前有7种类型Collector
则负责管理、采集、发送日志,核心方法就3个subscribe()
添加日志采集触发器Trigger
add()
添加日志flush()
发送日志
Clue
根据上图可知目前有7种日志类型
enum CLUE_TYPE {
USER = 'user',
HTTP = 'http',
BEHAVIOR_PAGE = 'page',
BEHAVIOR_EVENT = 'event',
JS_ERROR = 'js_error',
RESOURCE_ERROR = 'resource_error',
CUSTOM_ERROR = 'custom_error',
}
clue
本身不负责发送日志,仅仅表示一条日志,日志本身需要时间戳 + 数据,数据应该由上游处理好之后,传给 clue
,然后进行进一步的数据扩展、校验等。
class Clue {
type: CLUE_TYPE
timestamp: number
data: IClueData
constructor(type, d) {
this.type = type
this.timestamp = Date.now()
this.data = d
}
toJSON() {
return {
timestamp: this.timestamp,
...this.data,
}
}
}
由此基类可以派生出多种类型日志,鉴于我们这里设计的很简单,不考虑数据扩展、数据校验等,目前的区别不过是 type、data
不同而已。为此,为避免冗余,这里采用工厂模式创建日志对象
class Clue {
static User(d: IUserData) {
return new Clue(CLUE_TYPE.USER, d)
}
static CustomError(d: { message: string, level: string }) {
return new Clue(CLUE_TYPE.CUSTOM_ERROR, {
cate: ERROR_CATE.CUSTOM_ERROR,
...d,
})
}
}
Trigger
日志的数据结构定了,那数据从哪里来呢?这就要配置 trigger
,即指定什么时候采集数据
上图也可以看到,虽然有 7
种类型的日志,但是 trigger
只需要 4
个,也就是说,一个 trigger
可以采集多种类型的日志,是一对多的关系
另外,每个 trigger
采集的时机不一样,比如有些是等待网络请求,有些是等待点击事件,不过 collector
不必关心时机,只需要把 add()
告诉 trigger
,只要事件一发生,就调用 add()
添加到数组
怎么添加一个 trigger
呢? 通过 subscribe()
class Collector {
add() {}
subscribe(cb) {
cb(this.add.bind(this))
}
}
定义一个自己的 trigger
,比如
function testTrigger(add) {
add(new Clue('test_clue', { name: 'test' }))
}
UserTrigger
首先是 userData
,一般是用户访问应用就会发送,所以是和初始化 SDK
同步
const userProxy = (add) => {
add(Clue.User({
ua: getBrowser(),
uav: getBrowserVersion(),
os: getOS(),
osVersion: getOsVersion(),
sr: getScreenInfo()
}))
}
API Trigger
拦截 xhr
的请求和响应,有一个现成的库可以使用 ajax-hook
,根据文档配置即可
proxy({
onResponse(res, handler) {
add(Clue.Http(re.response), immediate)
handler.next(response)
}
})
Page Trigger
如果是 SPA
,需要监听路由变化
window.addEventListener('hashchange', () => {
add(Clue.EventPage({ href: window.location.href }))
})
Event Trigger
浏览器中,我们主要监听 click event
function eventProxy(add) {
window.addEventListener('click', (e) => {
const data = handle(e)
add(Clue.EventBehavior(data))
}, false)
}
Error Trigger
主要全局监听 window.error, window.unhandledrejection
window.addEventListener('error', (e: ErrorEvent | Event) => {
...
add(Clue.JSError({ message, stack }))
})
window.addEventListener('unhandledrejection', (e: ErrorEvent | Event) => {
...
add(Clue.JSError({ message, stack }))
})
Flush
最后一个问题,什么时候发送日志?怎么发?
发送策略
1、先看实时发送,只要 trigger
触发就上报一条日志(也就是数组长度始终是 1
)
pros
- 实时,日志不会丢失
cons
- 对于接口密集型应用,会造成网络堵塞
2、如果不实时发送,就要指定规则,比如满足多少条日志量、每隔一段时间上报
props
- 减缓网络堵塞
cons
- 数据不及时,会丢失(当然可以再制定策略避免丢失,比如放在缓存中,不过过于麻烦)
也可以二者结合起来,
- 一旦发生错误,立即上报当前队列所有日志
- 未发生错误之前,正常采集,满足条件才发送
3、虽然制定了上面的策略,但如果条件都没满足,页面被卸载,存储的日志是无法发送的。然而,在页面 unload
发送日志,是比较困难的事情,不过也有一些方法可以实现
- 创建一个同步的
XHR
即xhrReq.open(method, url, true)
- 通过
img.src
发送请求 - 在
unload
创建一个 noop 循环
但是这些方法也有一些不足
unload
在某些情况不会触发- 造成浏览器阻塞,要等请求完成才跳转等
sendBeacon
后来 W3C 发布了 sendBeacon - MDN,可通过 POST
将少量数据 异步非阻塞 传输到服务器。
pros
- 没有跨域问题,所以要注意数据安全
- 请求放在浏览器任务队列,不会阻塞页面卸载等
cons
- 通过
POST
只能发送少量数据,至于能发送多少数据量,并没有明确 - 只能知道数据是否成功加入队列,不保证成功发送,因此要降级
xhr
- 没有
callback
怎么发
综上,优先考虑 sendBeacon
降级为 xhr
function sendBeacon(url, data) {
if (navigator?.sendBeacon) {
return navigator.sendBeacon(url, data)
}
return false
}
function sendByXHR() {
const xhr = new XMLHttpRequest()
xhr.open('POST', url , true)
xhr.send(data)
}
function send(url, data) {
try {
if (data.length >= 32 * 1024) { // 32KB
sendByXHR(url, data)
}
else if (!sendBeacon(url, data)) {
sendByXHR(url, data)
}
} catch (e) {}
}