APM介绍
前端APM(Application Performance Manage)是对线上运行状况进行监控的一种手段,通过APM能够实时监控线上的脚本异常、页面白屏、网络延迟,以及从各个维度对各个应用的健康状况进行评估。
整体架构图
监控SDK
数据采集
错误类型:主要分为以下几种
-
语法错误、代码运行时发生的错误
-
静态资源加载错误
-
Promise调用链异常
错误捕获方式
- window.onerror
window.onerror 可以捕获 语法错误、代码运行时发生的错误
this.global.onerror = this.handleJsError;
- window.addEventListener('error')
window.addEventListener('error') 可以捕获 资源加载错误
this.global.addEventListener('error', this.handleResourceError, true);
但是,new Image().src = 'test.cn/×××.png' 这种错误无法被 addEventListener 捕获到,需要单独处理
- window.addEventListener('unhandledrejection')
可通过 unhandledrejection 事件来处理 Promise 中抛出的错误,Promise error 无法被 window.onerror、try/catch、 error 事件捕获到
this.global.addEventListener('unhandledrejection', this.handlePromiseError);
性能指标采集
web-vitals是 Google 给出的定义是 一个良好网站的基本指标
过去要衡量一个网站的好坏,需要使用的指标太多了,现在我们可以将重点聚焦于 Web Vitals 指标的表现即可
官方指标标准
| 指标 | 作用 | 标准 |
|---|---|---|
| FCP(First Contentful Paint) | 首次内容绘制时间 | 标准 ≤1s |
| LCP(Largest Contentful Paint) | 最大内容绘制时间 | 标准 ≤2s |
| FID(first input delay) | 首次输入延迟,标准是用户触发后,到浏览器响应时间 | 标准≤100ms |
| CLS(Cumulative Layout Shift) | 累积布局偏移 | 标准 ≤0.1 |
| TTFB(Time to First Byte) | 页面发出请求,到接收第一个字节所花费的毫秒数(首字节时间) | 标准<=100ms |
采集方式
利用 Google 提供的 web-vitals 库来获取指标
import {
getFCP, getLCP, getFID, getCLS, getTTFB,
} from 'web-vitals';
init = (config) => {
function addToQueue(metric) {
// 在合适的时机进行上报
}
getFCP(addToQueue);
getCLS(addToQueue);
getFID(addToQueue);
getLCP(addToQueue);
getTTFB(addToQueue);
};
数据上报
我们APM中有两种数据上报方式:sendBeacon 和 XmlHttpRequest。
- sendBeacon:使用浏览器后台队列,即使在页面关闭后也可以发送请求。因此,它适用于需要在页面卸载时发送数据的情况(当浏览器不支持 sendBeacon 或者 传输的数据量超过了 sendBeacon 的限制,我们就降级采用 xmlHttpRequest 进行上报数据)。
- XMLHttpRequest (XHR):是一种传统的网络请求方法,可以用于发送各种类型的请求;
上报时机
-
实时上报:对于脚本异常、资源加载异常、请求异常、白屏异常的数据,会实时上报
-
条件上报:
-
对于性能指标的数据,会在当浏览器的页面可见性状态发生变化、用户即将离开页面、关闭页面或导航到其他页面、面即将卸载这三种情况下进行上报
为什么这个上报时机这么奇怪?因为LCP这些指标,页面加载完成的时候可能无法立即获得准确的值
-
对于资源加载明细的数据,仅在LCP低于某个阈值时上报,用于分析问题
-
对于服务器的响应数据,会在计算出TTFB这个指标时就马上上报(一般打开页面就能计算出来,所以把这个指标定义为项目的pv)
-
加密 && 压缩
在数据上报之前,会对数据进行压缩
/**
* @name arrayBufferToBase64 arrayBuffer转base64
* @param {ArrayBuffer} buffer ArrayBuffer对象
* @returns {String} Base64字符串
*/
export const arrayBufferToBase64 = (buffer) => {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let len = bytes.byteLength, i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
};
// gzip压缩
const gzip = pako.gzip(formatStr);
// 加密
const encrypt = new JSEncrypt();
const timestamp = new Date().getTime();
encrypt.setPublicKey(PUBLIC_KEY);
const encrypted = encrypt.encrypt(`${APM_KEY}___${timestamp}`);
const reqUrl = `${uploadHost}${useImg ? UPLOAD_BEACON_API : UPLOAD_API}?key=${APM_KEY}&sign=${encrypted}×tamp=${timestamp}`;
const reqData = {
data: arrayBufferToBase64(gzip),
appv: appV,
systemtype: systemType,
appname: appName,
treatyversion: treatyVersion,
};
数据存储
我们的数据存储使用的是ClickHouse,ClickHouse 是一个开源的列式数据库管理系统,专为实时分析而设计。它针对分析场景提供了高性能、高并发的数据存储和查询能力,尤其擅长处理大规模数据集的聚合查询。(由于这个由专门的中间件团队维护,我们不是特别了解,也造成了后期我们功能迭代依赖于外部的大坑)
问题定位
目前线上的运行的代码都是经过压缩、混淆之后的乱码。我们如何能够在发现问题后,快速定位到源码的位置?Source-map。
实现原理
虽然我们不会把build后的souce-map直接上传到生产服务器上,但是我们任然可以保留一份souce-map到APM的服务器上。
通过错误中的的行列号信息,报错的文件地址,就可以定位到源码的错误位置。
文件上传的时机
我们公司是通过在构建流程中加入插件的形式完成的。当打包流程完成的时候,将打包出来的source-map文件压缩后上传到APM服务器
if (env === 'production' && process.env.NODE_ENV === 'production') {
const MAP_PATTERN = './build/**/*.map';
// 将 build 目录下的.map文件打成zip包
const zipPath = path.resolve(BUILD_PATH, `${root}.zip`);
const files = glob.sync(MAP_PATTERN);
const {
action,
name,
data,
} = this.options;
// 上传打包后的 map文件
this.upload({
files,
zipPath,
requestOpts: {
action,
name,
data: typeof data === 'function' ? data(root) : data,
},
}).then(() => {
// 上传完成之后删除.zip包和.map文件
this.deleteSourceMaps(zipPath);
success('SourceMap同步成功');
callback();
}).catch((err) => {
error(err);
// 上传出错也需要 删除.zip包和.map文件
this.deleteSourceMaps(zipPath);
callback();
});
}
- 上传完成之后,不管成功失败,都需要删除.map和 .zip文件,以免上传到生产服务器
错误解析
现在我们已经有了sourmap文件,那么只需要具体的错误信息,就可以定位到源代码的位置了。APM后端服务中提供了一个接口来做这件事情。主要流程
- 拿到报错的原始信息
- 将错误信息解析成错误栈列表
- 通过source-map这个npm包获取源代码的位置
- 截取错误位置的部分上下文,返回给前端
我们的原始错误信息可能长这样:(当然还有PromiseError的情况)
TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
at https://cdnmobile.xxx.cn/xxx/main.6ed01ed7.js:1:207065
at y (https://cdnmobile.xxx.cn/xxx/main.6ed01ed7.js:1:207127)
at FETCH_COUPON_LIST (https://xx.xxx.cn/xx/main.6ed01ed7.js:1:207795)
at Object.FETCH_COUPON_LIST (https://cdnmobile.xxx.cn/coupon-mall/vendors~main.39e6ea90.js:27:19229)
at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:19965
at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:32252
at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:32252
at v (https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:30187)
at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:44:2461
at dispatch (https://xxx.xxx.cn/xxx/xxx.39e6ea90.js:27:33413)
封装了一个parsePromiseStacks方法用来解析错误栈,解析完的格式如下
const errorStacks = parsePromiseStacks(msg);
// [{...},{...},{...},{...},{...}]
// item: {
// "stack":"https://xxx/xxx/main.6ed01ed7.js:1:208706",
// "colno":208706,
// "lineno":1,
// "filename":"https://xxx/xxx/main.6ed01ed7.js"
// }
循环遍历errorStacks根据行列信息查看在源代码中的信息
// 查找sourcemap
function lookupSourceMap(mapFile, line, column) {
// ...
return new Promise(function (resolve) {
// mapFile为 .map 文件
fs.readFile(mapFile, async function (err, data) {
// ... fileContent
let consumer = await new sourceMap.SourceMapConsumer(fileContent);
const lookup = {
line: parseInt(line, 10),
column: parseInt(column, 10),
};
const result = consumer.originalPositionFor(lookup);
// result:
// {
// column: 39,
// line: 80,
// name: "waitPayCount",
// source: "webpack:///src/reducers/couponCenter.js",
// sourcesContent: "import { combineReducers } from 'redux';\nimport { buildReduce } from 'util';\n\n//",
// }
resolve(result);
});
});
}
虽然这个时候定位到了错误,但是我们需要一些context,能对错误信息一目了然
const getErrorCode = (result, lineLen) => {
const { file, row, column, source, stack } = result;
// 上下文的长度
const ll = lineLen || 6;
if (file && column !== null && row !== null) {
const lines = result.file.split('\n');
const hafLen = Math.floor(ll / 2);
const len = lines.length - 1;
const start = row - hafLen >= 0 ? row - hafLen : 0;
const end = start + ll - 1 >= len ? len : start + ll - 1; // 最多展示6行
const newLines = [];
for (let i = start; i <= end; i++) {
newLines.push(`${i + 1}. ${lines[i]}`);
}
return {
row,
column,
source,
stack,
errorCode: newLines,
};
}
return { stack };
};
错误解析完成后,返回给前端,在APM管理页面上展示出来
定时清理
由于每次发布上线都会上传一下source-map,那么时间长了必然会导致服务器的磁盘被打满,所以需要定时清理无用的source-map文件,清理策略:
- 每天早上
9:50的时候,将七天前的source-map文件清除(因为错误日志最多保留七天) - 清除的时候有个条件:至少保留一个最新版本的source-map文件(防止七天内没有发布)
异常告警
告警规则
APM中针对脚本异常、资源加载异常、请求异常、满页面、白屏异常,可以配置对应的告警规则
静态规则
每10分钟的告警次数超过x个,就会告警
动态规则
告警的个数相对于前10分钟,有轻微增加、增加、较明显增加、明显增加的时候,会告警,对应的比例如下
const RULEOPTION = {
3.36: '明显增加',
2.02: '较明显增加',
1.47: '增加',
1.282: '轻微增加',
};
// 触发告警的代码
try {
// 动态规则
if (item.ruleType === 1) {
// 最近10分钟的报错数
const dataList = await getCommonList(body);
// 前10分钟的报错数
const lastDataList = await getCommonList(lastBody);
const exceptionNum = dataList.length;
const lastExceptionNum = lastDataList.length;
let result = Infinity;
let isWarning = true;
if (exceptionNum === 0) {
isWarning = false;
} else if (exceptionNum !== 0 && lastExceptionNum !== 0) {
result = exceptionNum / lastExceptionNum;
}
// 如果比例大于 RULEOPTION 中的比例,那么就会触发告警
if (isWarning && item.targetValue < result) {
// 调用钉钉hooks
alarm(item, { exceptionNum, targetValue: item.targetValue });
}
// 静态规则
} else {
const dataList = await getCommonList(body);
const exceptionNum = dataList.length;
if (item.targetValue < exceptionNum) {
// 调用钉钉hooks
alarm(item, { exceptionNum });
}
}
} catch (e) {
console.log(e);
}
告警方式
在APM服务中,会跑一个定时任务,每隔10分钟会将数据库中的所有规则拿出来跑一下,如果发现有命中了规则的,就会调用钉钉机器人hook,发送钉钉告警
// 钉钉机器人告警信息
const dingRobot = {
// 消息类型markdown
msgtype: 'markdown',
markdown: {
// 标题
title: 'APM告警',
// 内容
text: `#### APM告警提醒
\n 应用名称:${appName}
\n 告警类型:${EVENTTYPE[eventType]}
\n 告警内容:${EVENTTYPE[eventType]}指标超过阈值,${alarmMsg}
\n 告警时间:${currentTime}
${atStr ? `\n 应用负责人:${atStr}` : ''}
\n [查看详情](跳转链接的地址)`,
},
at: {
atMobiles,
},
};
let data;
const params = {
method: 'post',
url,
data: dingRobot,
};
// !!! 这个url是在钉钉群里面生成的
if (url) {
data = await request(params);
}
数据看板
这里简单说一下功能点,就不上图了
健康分计算
对于每个项目,会根据js报错率、资源异常率、请求异常率、LCP合格率、白屏率等算出一个健康分
排行榜
以项目维度对所有的项目根据健康分进行排序
英雄榜
从负责人的维度,对所有人的健康分平均分进行通晒,排名
需要完善的地方
- 有很多异常报错。是由于用户网络较差导致的,如何去监控到用户的弱网情况,并且过滤掉这种报错,降低误报率。
- sendBeacon中传输的数据量超过了限制的情况,可能需要处理一下。