背景
自研工具是为了解决内部问题而生,希望通过这些问题引起大家的共鸣:
- 是否知道重要的业务,该页面是可以正常服务于用户的?
- 能否在问题还没有大规模爆发之前,快速的感知到业务的异常?
- 怎么不去用户的电脑上就能直观的看到问题所在,从而俯瞰项目全局;能否从宏观到微观一路下钻快速的定位线上告警信息?
- 在跨部门沟通时拿出合理的证据,来告诉他这个时间段该接口就是无法访问的,并告知我们的参数传的很正确,帮助服务端反查问题。
- 产品和设计同学想要提升用户体验,研发不断迭代功能版本。那这些我们以为的优化点,效果究竟如何?怎么去衡量?
- 哪个广告位,哪个资源位更有价值?怎么能更为精准的触达用户痛点,为提升业务赋能?
我们看到这些疑问,都需要数据指标的支撑。从解决这些问题的角度出发,把反复出现或无法跟其他部门交代的问题,打造成可以帮助我们解决问题的产品。
所以在这种场景下,易车·前端监控应运而生。
它主要是多场景多维度实时的监控大盘,实现浏览器客户端的全链路监控,方便团队事后追查和整改,转变为事前预警和快速判定根因。
经过详细的规划以后,我们把前端监控分为四期,分别为:异常监控(一期)、性能监控(二期)、数据埋点(三期)、行为采集(四期),于 2020 年 6 月 23 号正式启动研发,目前处于二期阶段。
关键结构
为实现上述需求,监控系统主要分为四个阶段来实现;分别是:指标采集、指标存储、统计与分析、可视化展示。
指标采集阶段:通过前端集成的 SDK 收集请求、性能、异常等指标信息;在客户端简单的处理一次,然后上报到服务器。
指标存储阶段:用于接收前端上报的采集信息,主要目的是数据落地。
统计与分析阶段:自动分析,通过数据的统计,让程序发现问题从而触发报警。人工分析,是通过可视化的数据面板,让使用者看到具体的日志数据,从而发现异常问题根源。
可视化展示阶段:通过可视化的平台;在这些指标(API 监控、异常监控、资源监控、性能监控)中,追查用户行为来定位各项问题。
整体架构图
随着统计需求的增加以及前端应用的上线,数据量由早期的每天 100 多万条数据;到现在的每天约 7000 万条数据。架构上也经历了三次版本的迭代。这是最新版的架构图,主要经过 6 层处理。
采集层:PC 和 H5 使用了一套 SDK 监听事件采集指标,然后将监听到的指标通过 REST 接口往 Logback 推送数据。Logback 以长连接的方式,会把这些不同类型的指标数据推送到 Flume 集群当中。Flume 集群会将这些数据,分发到 Kafka Topic 进行存储。
处理层:由 Flink 去实时消费;Flink 会消费三种类型,分别是:离线数据落地、实时 ETL+图谱、明细日志。
存储层:离线数据会存储到 HDFS 中;实时 ETL+图谱数据会存储到 MySQL 中;明细数据会落入到 ES 中。
统计层:离线(DW、DM)、实时(分钟级->十分钟级->小时级)的方式,对指标进行汇总和统计。
应用层:最后由接口去汇总表和明细 ES 里查询数据。
展示层:然后前端输出图表、报表、明细、链路等信息。
技术方案
数据采集
采集最初的愿景是希望对业务无侵入性,业务系统无需改造,只需要嵌入一段代码即可。所以这些采集,都是 SDK 自动化的处理。
SDK 会全局监听几个事件,分别为:错误监听、资源异常的监听、页面性能的监听、API 调用的监听。
通过这几项监听,最终汇总为 3 项指标的采集。
异常采集:调用 error/unhandledrejection 事件,用于捕获 JS、图片、CSS 等资源异常信息。**
性能采集:调用浏览器原生的 performance.timing API 捕获页面的性能指标。
接口采集:通过 Object.definePropety 代理全局的 XHR 用于捕获浏览器的 XHR/FETCH 的请求。
采集端 SDK 架构
SDK 主要分为两部分:
第一部分:SDK 主要是 SDK 的驱动,包含:入口、核心工具以及通用类型的推断。
第二部分:也叫做插件部分(蓝色区域),主要实现上面的三项数据指标的采集。
接下来主要会详细的介绍第二部分,各项指标的采集方案。
异常采集方案
通过监听 error 错误,即可捕获到所有(JS 错误、图片加载、CSS 加载、JS 加载、Promise 等)异常;它也支持 InternalError、ReferenceError 等 7 种错误捕获。
以下是关键性代码。
监听事件
/**
* 监听 error、unhandledrejection 方法处理异常信息
*
* @param {YicheMonitorInstance} instance SDK 实例
*/
export default function setupErrorPlugin(instance: YicheMonitorInstance) {
// JS 错误或静态资源加载错误
on('error', (e: Event, url: any, lineno: any) => {
handleError(instance, e, url, lineno);
});
// Promise 错误,IE 不支持
on('unhandledrejection', (e: any) => {
handleError(instance, e);
});
}
判断异常类型
/**
* W3C 模式支持 ErrorEvent,所有的异常从 ErrorEvent 这里取
*
* @param {MutationEvent} error 资源错误、代码错误
*/
function handleW3C(event: any) {
switch (event.type) {
// 判断脚本错误,还是资源错误
case 'error':
event instanceof ErrorEvent
? reportJSError(instance, event)
: reportResourceError(instance, event);
break;
// Promise 是否存在未捕获 reject 的错误
case 'unhandledrejection':
reportPromiseError(instance, event);
break;
}
}
捕获异常数据
/**
* 上报 JS 异常
*
* @param {YicheMonitorInstance} instance SDK 实例
* @param {ErrorEvent} event
*/
export default function reportJSError(
instance: YicheMonitorInstance,
event: ErrorEvent,
): void {
// 设置上报数据
const report = new ReportDataStruct('error', 'js');
const errorInfo = event.error
? event.error.message
: `未知错误:${event.message}`;
// 设置错误信息,兼容远程脚本不设置 Script error 导致的异常
report.setData({
det: errorInfo.substring(0, 2000),
des: event.error ? event.error.stack : '',
defn: event.filename,
deln: event.lineno,
delc: event.colno,
rre: 1,
});
}
处理 IE 兼容问题
捕获异常时处理下 IE 的兼容性问题即可,IE 的方案如下:
/**
* IE 8 的错误项,所以针对于 IE 8 浏览器,我们只需要获取到它出错了即可。
*
* 1. 错误消息
* 2. 错误页面
* 3. 错误行号(因为文件通常是压缩的,所以统计 IE8 的行号是没有任何意义的)
*
* @param {string} error 错误消息
* @param {string | undefined} url 异常的 URL
* @param {number | undefined} lineno 异常行数,IE 没有列数
*/
export function handleIE8Error(
error: string,
url?: string | undefined,
lineno?: number | undefined,
) {
return {
colno: 0,
lineno: lineno,
filename: url,
message: error,
error: {
message: error,
stack: `IE8 Error:${error}`,
},
} as ErrorEvent;
}
/**
* IE 9 的错误,需要在 target 里面获取到
*
* @param { Element | any } error IE9 异常的元素
*/
export function handleIE9Error(error: any) {
// 获取 Event
const event = error.currentTarget.event;
return {
colno: event.errorCharacter,
lineno: event.errorLine,
filename: event.errorUrl,
message: event.errorMessage,
error: {
message: event.errorMessage,
stack: `IE9 Error:${event.errorMessage}`,
},
} as ErrorEvent;
}
性能采集方案
浏览器页面加载过程
性能指标获取方式
我们借助于浏览器原生的 Navigation Timing API 能够获取到上述页面加载过程中的各项性能指标数据,用于性能分析,它的时间单位是纳秒级。
当然也借助于 PerformanceObserver API 等用于测量 FCP、LCP、FID、TTI、TBT、CLS 等关键性指标。
详细的计算公式
指标 | 含义 | 计算公式 |
---|---|---|
ttfb | 首字节时间 | timing.responseStart – timing.requestStart |
domReady | Dom Ready时间 | timing.domContentLoadedEventEnd – timing.fetchStart |
pageLoad | 页面完全加载时间 | timing.loadEventStart – timing.fetchStart |
dns | DNS 查询时间 | timing.domainLookupEnd – timing.domainLookupStart |
tcp | TCP 连接时间 | timing.connectEnd – timing.connectStart |
ssl | SSL 连接时间 | timing.secureConnectionStart > 0 ? timing.connectEnd – timing.secureConnectionStart) : 0 |
contentDownload | 内容传输时间 | timing.responseEnd – timing.responseStart |
domParse | DOM 解析时间 | timing.domInteractive – timing.responseEnd |
resourceDownload | 资源加载耗时 | timing.loadEventStart – timing.domContentLoadedEventEnd |
waiting | 请求响应 | timing.responseStart – timing.requestStart |
fpt | 白屏时间,老 | timing.responseEnd – timing.fetchStart |
tti | 首次可交互 | timing.domInteractive – timing.fetchStart |
firstByte | 首包时间 | timing.responseStart – timing.domainLookupStart |
domComplete | DOM 完成时间 | timing.domComplete – timing.domLoading |
fp | 白屏时间,新指标 | performance.getEntriesByType(‘paint’)[0] |
fcp | 首次有效内容绘制 | performance.getEntriesByType(‘paint’)[1] |
lcp | 首屏大内容绘制时间 | PerformanceObserver(‘largest-contentful-paint’)” |
快开比 | 页面完全加载时长 ≤ 某时长(如2s)的 采样PV / 总采样PV * 100% | |
慢开比 | 页面完全加载时长 ≥ 某时长(如5s)的 采样PV / 总采样PV * 100% |
网络请求采集方案
网络请求,通过 Object.definePropety 的方式对 XHR 做的代理。关键性代码如下。
重写 XMLHttpRequest
这部分可以直接参考 ajax-hook 的实现原理。
export function hook(proxy) {
window[realXhr] = window[realXhr] || XMLHttpRequest
XMLHttpRequest = function () {
const xhr = new window[realXhr];
for (let attr in xhr) {
let type = "";
try {
type = typeof xhr[attr]
} catch (e) {
}
if (type === "function") {
this[attr] = hookFunction(attr);
} else {
Object.defineProperty(this, attr, {
get: getterFactory(attr),
set: setterFactory(attr),
enumerable: true
})
}
}
const that = this;
xhr.getProxy = function () {
return that
}
this.xhr = xhr;
}
return window[realXhr];
}
拦截所有请求
正常的情况下一个页面会请求多个接口,假如有 20 个请求;
我们期望在阶段性的所有请求都结束已后,汇总成一条记录合并上报,这样能有效减少请求的并发量。
关键性代码如下:
/**
* Ajax 请求插件
*
* @author wubaiqing <wubaiqing@vip.qq.com>
*/
// 所有的数据请求,以及总量
let allRequestRecordArray: any = [];
let allRequestRecordCount: any = [];
// 成功的数据,200,304 的数据
let allRequestData: any = [];
// 异常的数据,超时,405 等接口不存在的数据
let errorData: any = [];
/**
* 监听 Ajax 请求信息
*
* @param {YicheMonitorInstance} instance SDK 实例
*/
export default function setupAjaxPlugin(instance: YicheMonitorInstance) {
let id = 0;
proxy({
onRequest: (config, handler) => {
// 过滤掉听云、福尔摩斯、APM
if (filterDomain(config)) {
// 添加请求记录的队列
allRequestRecordArray.push({
id,
timeStamp: new Date().getTime(), // 记录请求时长
config, // 包含:请求地址、body 等内容
handler, // XHR 实体
});
// 记录请求总数
allRequestRecordCount.push(1);
id++;
}
handler.next(config);
},
// 失败时会触发一次
onError: (err, handler) => {
if (allRequestRecordArray.length === 0) {
handler.next(err);
return;
}
for (let i = 0; i < allRequestRecordArray.length; i++) {
// 当前的数据
const currentData = allRequestRecordArray[i];
if (
currentData.handler.xhr.status === 0 && // 未发送
currentData.handler.xhr.readyState === 4
) {
errorData.push(
JSON.stringify(handleReportDataStruct(instance, currentData)),
);
allRequestRecordArray.splice(i, 1);
}
}
sendAllRequestData(instance);
handler.next(err);
},
onResponse: (response, handler) => {
// 没有请求就返回 Null
if (allRequestRecordArray.length === 0) {
handler.next(response);
return;
}
for (let i = 0; i < allRequestRecordArray.length; i++) {
// 当前的数据
const currentData = allRequestRecordArray[i];
// 只要请求加载完成,不管是成功还是失败,都记录是一次请求
if (currentData.handler.xhr.readyState === 4) {
// 正常的请求
if (
(currentData.handler.xhr.status >= 200 &&
currentData.handler.xhr.status < 300) ||
currentData.handler.xhr.status === 304
) {
allRequestData.push(
JSON.stringify(handleReportDataStruct(instance, currentData)),
);
} else {
if (currentData.handler.xhr.status > 0) {
// 具备状态码
// 错误的请求
errorData.push(
JSON.stringify(handleReportDataStruct(instance, currentData)),
);
}
}
// 删除当前数组的值
allRequestRecordArray.splice(i, 1);
}
}
// 发送数据
sendAllRequestData(instance);
handler.next(response);
},
});
}
function sendAllRequestData(instance) {
if (
allRequestData.length + errorData.length ===
allRequestRecordCount.length
) {
// 处理正常请求
if (allRequestData.length > 0 || errorData.length > 0) {
handleAllRequestData(instance);
}
// 处理异常请求
if (errorData.length > 0) {
handleErrorData(instance);
}
// 所有的数据请求,以及总量
allRequestRecordArray = [];
allRequestRecordCount = [];
// 成功的数据,200,304 的数据
allRequestData = [];
// 异常的数据,超时,405 等接口不存在的数据
errorData = [];
}
}
探针加载方案
探针加载有两种方式,他们分别有一些优缺点:
同步加载:采集 SDK 放到所有 JS 请求头的前面;因为加载顺序的问题,如果放在其他 JS 请求之后,之前的 JS 出现了异常,就捕获不到了。因为要提前加载 JS 资源,会对性能有一定影响。
异步加载:采集 SDK 通过执行 JS 后注入到页面中;如果能保障首次的 JS 无异常,也可以使用异步的方式加载 SDK,对首屏优化有好处。
目前我们采用的是第一种同步加载的方式。
产品部分截图
首页
首页会展示所有应用的情报,在首页可以直观的发现各应用的异常数据。
大盘页面
如果想对某个应用细项的排查,会进入到应用的大盘页面;
主要会展示该应用,前端的重要性指标,近一个小时内的数据状况。
目前主要有页面性能、资源异常、JS 异常、API 接口成功率等重要指标作为衡量。
详情页
详情页,就可以看到该应用某项指标的数据细项。方便团队进行事后的追查、整改,提前预警和快速判定根因所用。
遇到的问题
SDK 采集到指标以后对数据进行上报时,会做一些过滤性的前置操作,如:
- 屏蔽掉一些黑名单。
- 指标的削峰填谷。
- 应用信息的转换。
- 客户端 IP 获取。
- Token 的验证。
前置处理有一个弊端,因为服务器会经过解析转换环节;当数据量达到每日 7000 万左右,上报的服务器就扛不住了。
所以我们把数据前置处理,变为数据落地后置处理;后置处理就是在数据清洗的过程中,在过滤掉黑名单以及异常指标。这样就减轻了上报服务器的压力。
并且仓库也会保留所有的原始数据,如果出现异常的时,也方便我们溯源,对数据进行恢复。
整体规划
我们分为了四期,目前还处于二期性能监控阶段。
计划 | 目标 | 优先级 | 支持平台 | 主要解决的问题点 |
---|---|---|---|---|
一期 | 异常监控 | 高 | PC、Mobile、小程序 | 异常影响的影响用户,资源加载异常感知,网络请求异常感知,代码报错异常感知,代码报错的细项(SourceMap)分析 |
二期 | 性能监控 | 高 | 性能值(首字节、DOMReady、页面完全加载、重定向、DNS、TCP、请求响应等耗时),API 监控(成功率、成功耗时、失败次数等),页面引用资源统计,和资源占比(JS、CSS、图片、字体、iFrame、Ajax 等),位数对比,95% 的用户、99% 的用户、平均用户 | |
三期 | 数据埋点 | 中 | 操作系统、分辨率、浏览器,事件分类(点击事件、滚动事件),具体的指定的事件类型(点击 Banner 图),事件发生时间,触发事件的位置(鼠标 X、Y,可生成热力图),访客标识,用户标识,链路采集 | |
四期 | 行为采集 | 低 | 进入页面,离开页面,点击元素,滚动页面,操作链路,自定义(如,点击广告位的图),Chrome 插件直观看到埋点 |
其它
自研 APM 系统方便与内部进行的打通和整合;比如应用发布后就可以直接推送 SourceMap 文件;并且能实现线上发布以后自动进行页面性能的分析等工作。
如果目前发展阶段还不需要自建一个这样的系统,但业务需要这样的能力,也可以考虑第三方的一些产品。
商业产品分析
易车 | 听云 | 阿里云 ARMS | Fundebug | 岳鹰 | FrontJS | |
---|---|---|---|---|---|---|
页面性能监控 | 功能齐全 | 基础功能 | 功能齐全 | 弱 | 功能齐全 | 功能齐全 |
异常监控 | 基础功能 | 基础功能 | 功能齐全 | 功能齐全 | 功能齐全 | 功能齐全 |
API 监控 | 功能齐全 | 基础功能 | 功能齐全 | 基础功能 | 基础功能 | 基础功能 |
页面加载瀑布图 | 无 | 功能齐全 | 基础功能 | 无 | 无 | 功能齐全 |
交互性 | 好 | 一般 | 好 | 不清晰 | 好 | 好 |
重要性指标对和阿里 ARMS 对比
易车·前端监控和阿里云 ARMS 做了一些重要性的指标对比,均值的浮动在上下在 5%-8% 左右;
参考链接
- User Timing API
- Long Tasks API
- Element Timing API
- Navigation Timing API
- Resource Timing API
- Server timing
- Custom Metrics
- Lighthouse performance scoring
- FCP
- LCP
- FID
- TTI
- TBT
- CLS