在实际的项目开发,布署上线过程中,经常会遇到如下问题:

  1. 系统上线后,用户发现访问页面速度 变慢了。项目排查认为是框架问题,让框架进行排查;框架排查认为是网络问题与框架无关。 那到底如何明确定位到底是属于网络问题、框架问题、还是业务逻辑问题呢?
  2. 系统上线后,发现系统不稳定 ,界面操作有报错现象。但公司目前现场发过来的错误日志,只有后端日志,没有前端报错日志,前端人员需要浏览器控制台报错信息才能进行排查,同时开发人员也不清楚复现步骤
  3. 随着系统页面越来越多,功能越来越复杂,需要能持续监控、评估、预警页面性能状况、发现瓶颈,指导优化工作的进行。

为了解决上面的问题,需要进行前端监控。前端监控分为采集、存储、分析。下面主要介绍 前端监控之数据采集。

前端页面访问速度

网络耗时数据可以借助Performance API 获取,通过此接口可以获取 重定向耗时、DNS查询耗时、TCP链接耗时、HTTP请求耗时、解析DOM树耗时、DOM Ready时间、onload时间等耗时,示意图如下所示:

前端API请求监控与调用链iTracing 后端打通

原有的公司调用链iTracing是从nginx接收到请求开始记录的 , 加入前端监控链路后,它会将API请求从前端发出到后端调用的链路串联起来,真实还原代码执行的完整现场,能够全面准确确定系统性能瓶颈。

实现原理:前端不管是通过什么样的框架或库,底层都是调用两个最基本的web api XMLHttpRequest、Fetch才能进行数据请求。

通过修改XMLHttpRequest、Fetch的原型(prototype),拦截用户请求。在发送请求时开启事件监听、在请求返回结果时进行拦截。可以获取 API请求列表、返回信息列表、HTTP状态码等信息,通过Response header可以获取最重要的微服务调用链 ITRACING_TRACE_ID: 010010168049_84961_1^1665555540177^14,通过 微服务调用链ITRACING_TRACE_ID 可以显示出后端所有的调用链。

具体实现步骤如下:


1.  在前端拦截XMLHttpRequest请求 
 // 伪代码
(function (xhr) {
  var send = xhr.send;
  xhr.send = function (data) {
    onLoadStart.apply(this,aguments);
    this.addEventListener('loadend', onLoadEnd);
    this.addEventListener('error', onError);
    return send.apply(this, arguments);
  };
})(XMLHttpRequest.prototype); 
2.  在 请求开始前的 onLoadStart中 生成一个 前端Trace ID(跟踪本次前端请求),并放入请求头中。 
  // 伪代码
function onLoadStart() {
  let traceid = genTraceId();
  this.setRequestHeader("uber-trace-id",traceid);
  //插入本地日志待发送缓存队列中
  insertLogBuffer({traceid:traceid, beginTime:new Data(), ...})
} 
3.  在 请求结束后的 onLoadEnd中,根据 'Respone Headers' 返回的ITRACING_TRACE_ID,以后前端Trace ID,补充本次前端请求日志的其他信息 
  // 伪代码
function onLoadEnd() {
  let uberTraceId = this.getResponseHeader("uber-trace-id");
  let iTracingTraceId = this.getResponseHeader("ITRACING_TRACE_ID");
  //根据前端的traceid,补充本次前端请求日志的其他信息
  updateLogBuffer(uberTraceId, {
    ITRACING_TRACE_ID:iTracingTraceId, 
    costTime: new Data()- beginTime, 
    url, 
    httpCode,   //请求返回的状态
    ip
    ...})
}
4.  每隔一段时间 取日志缓存中的日志一起发向 日志中心。 
应用名	日志产生时间(beginTime)	状态(httpCode)	ip	服务名(url)	方法堆栈	时间轴(costTime)	客户端
A	10/12 15:03:12	404	10.45.18.240	/getUserInfo		9ms	Chrome/78.0.3904.108
A	10/12 15:04:12	200	10.45.18.240	/check	010010168049_84961_1^1665555540177^14	15ms	Chrome/78.0.3904.108

5.根据方法堆栈中的 iTracingTraceId可以获取查看后端调用链占用时间


  1. 通过上例 我们可以知道 从前端发起请求,至前端接收到请求 共花了15ms,其中后端总共花了 11ms。大约有4ms花了在网络链路

前端错误诊断

前端错误捕获

通常我们能捕获错误异常的手段和范围,如下表 捕获异常手段 同步方法 异步方法 资源加载 Promise async/await try/catch ✓ ✓ window.onerror ✓ ✓ error事件 ✓ ✓ ✓ unhandledrejection ✓ ✓

默认情况下,我们使用window.addEventListener('error', this.onError, true);去监听用户错误脚本;使用unhandledrejection 捕获未被捕获的Promise的异常,自动上报。

用户使用的有些前端框架会捕获js错误,错误信息不会抛至window.onError、error事件,这种情况需用户手动添加调用

  1. React: // 伪代码 最外层包一个根组件,捕获react异常 componentDidCatch(error) { // Display fallback UI this.setState({ hasError: true }); // You can also log the error to an error reporting service logErrorToMyService(error); }
  2. Vue: // 伪代码 const errorHandler = (error)=>{ console.error(error); logErrorToMyService(error); } Vue.config.errorHandler = errorHandler; Vue.prototype.$throw = (error)=> errorHandler(error,this);

源码定位

现在开发的大部分前端项目,都会采用前端工程化打包工具,比如gulp、grunt、webpack等等,最终编译出的代码,都会对源码进行混淆压缩,在真实线上项目,捕获js抛出的出错信息往往是压缩后的位置和变量信息,根本看不懂。 如:at f (http://xxxxx/runtime.0ed5fae112953bd474f7.js:1:27956

如何解决这个问题? 可利用Source Map还原代码真正的错误位置,这样使得开发者能够迅速定位出错的源代码位置以及相应的代码块。 采用 source-map-visualization等库,可输入压缩后的行列号,以及sourcemap可以定位出错的源代码位置以及相应的代码块

静态资源监测

资源监测分析主要使用 Resource Timing APIperformance.getEntriesByType("resource") 可以获取到资源加载的各个阶段的时间戳,如重定向、DNS 查询、TCP 连接建立。这些阶段和他们的属性名见下图:

可以使用这些属性值去计算某个阶段的耗时长度,用来帮助诊断性能问题 // 伪代码 var resources = performance.getEntriesByType("resource");

console.log("= Calculate Load Times"); for (var i=0; i < resources.length; i++) { console.log("== Resource[" + i + "] - " + resources[i].name); // Redirect time 重定向耗时 var t = resources[i].redirectEnd - resources[i].redirectStart; console.log("... Redirect time = " + t);

// DNS time  DNS 查询耗时
t = resources[i].domainLookupEnd - resources[i].domainLookupStart;
console.log("... DNS lookup time = " + t);

// TCP handshake time  TCP 握手耗时
t = resources[i].connectEnd - resources[i].connectStart;
console.log("... TCP time = " + t);

// Secure connection time SSL握手时间(HTTPS协议会有SSL握手)  
t = (resources[i].secureConnectionStart > 0) ? (resources[i].connectEnd - resources[i].secureConnectionStart) : "0";
console.log("... Secure connection time = " + t);

// Response time  在浏览器收到服务器响应的第一个字节 至 最后一字节的时间 
t = resources[i].responseEnd - resources[i].responseStart;
console.log("... Response time = " + t);

// Fetch until response end 浏览器准备好使用HTTP请求 至 最后一字节的时间
t = (resources[i].fetchStart > 0) ? (resources[i].responseEnd - resources[i].fetchStart) : "0";
console.log("... Fetch until response end time = " + t);

// TTFB 首包时间
t = (resources[i].requestStart > 0) ? (resources[i].responseStart - resources[i].startTime) : "0";
console.log("... TTFB time = " + t);

// Start until reponse end 本次请求总时长
t = (resources[i].startTime > 0) ? (resources[i].responseEnd - resources[i].startTime) : "0";
console.log("... Start until response end time = " + t);

}

除了监控时长外,也可以监控资源的大小 /* 应用程序资源的大小可能会影响应用程序的性能,因此获取有关资源大小的准确数据非常重要(尤其是对于非托管资源)。 PerformanceResourceTiming接口具有三个属性,可用于获取有关资源的大小数据。 transferSize属性返回获取的资源的大小(以八位字节为单位),包括响应头字段和响应有效载荷主体。 encodedBodySize属性返回从有效内容主体的提取(HTTP或缓存)接收的大小(以八位字节为单位),然后再删除任何应用的内容编码。 decodedBodySize从抓取(HTTP或高速缓存)的接收到的返回的大小(以字节)消息主体,之后除去任何施加的内容编码。


*/
function display_size_data(){

  var list = performance.getEntriesByType("resource");

  // For each "resource", display its *Size property values
  console.log("= Display Size Data");
  for (var i=0; i < list.length; i++) {
    console.log("== Resource[" + i + "] - " + list[i].name);
    if ("decodedBodySize" in list[i])
      console.log("... decodedBodySize[" + i + "] = " + list[i].decodedBodySize);
    else
      console.log("... decodedBodySize[" + i + "] = NOT supported");

    if ("encodedBodySize" in list[i])
      console.log("... encodedBodySize[" + i + "] = " + list[i].encodedBodySize);
    else
      console.log("... encodedBodySize[" + i + "] = NOT supported");

    if ("transferSize" in list[i])
      console.log("... transferSize[" + i + "] = " + list[i].transferSize);
    else
      console.log("... transferSize[" + i + "] = NOT supported");
  }
}

用户行为回溯

用户行为回溯: 是把页面上发生的各个事件节点定义为用户行为,包括页面加载、路由跳转、页面单击、API请求、控制台输出等信息,日志中心 按照时间顺序将用户行为串联起来就是用户的行为链路。通过出错时的行为回溯可以帮助开发者复现问题

如:下例所示 时间 事件类型 事件内容 09:09:47 页面浏览 http://10.10.168.49:54712/check.html 09:09:55 foucsout select标签(出版社) select-value:国家人民出版社 09:09:56 点击 BUTTON标签 (查询) 09:09:56 request post http://10.10.168.49:54712/ searchbook FormData: 09:09:57 response 状态:200 ** 09:09:58 发生错误 CustomizeError: 09:09:58 控制台输出 CustomizeError: Warning: Each child in a list should have a unique "key" prop.%s%s See https://fb.me/react-warning-keys for more information.%s

其中涉及到

  1. 页面访问监控
  2. 页面元素监控
  3. API请求拦截
  4. 控制台拦截

其中 1. 页面访问监控 与 3. API请求拦截 已经在上面介绍了。

下面介绍一下 页面元素控制 及 控制台拦截 技术实现

页面元素监控

实现原理:给整个window对象的unload、click、dblclick、keydown、focus事件设置监听器。当监听到这几类事件时,会执行dealEvent方法进行数据上传。

例:PTO js 全埋点部分实现 //监听页面离开unload的事件 window.addEventListener('unload', function (e) { endDate = new Date(); e.restTime = endDate - startDate; e.listnerEvent = "unload"; bp_config.dealEvent(e); }, true);

//监听click的事件 window.addEventListener('click', function (e) { clearTimeout(clickTimeId); clickTimeId = setTimeout(function() { e.listnerEvent = "click"; bp_config.dealEvent(e); }, 500); }, true);

//监听双击dbclick事件 window.addEventListener('dblclick', function (e) { clearTimeout(clickTimeId); e.listnerEvent = "dblclick"; bp_config.dealEvent(e); }, true);

//监听按键事件 window.addEventListener('keydown', function (e) { e.listnerEvent = "keydown"; if(e.altKey||e.shiftKey||e.ctrlKey||e.metaKey){ e.preventDefault(); if(e.keyCode != 16&&e.keyCode != 17&&e.keyCode != 18&&e.keyCode != 36 ){//组合键被按下 bp_config.dealEvent(e); } } }, true); //监听input的事件 for (var i = 0; i < inputList.length; i++) { inputList[i].addEventListener('focus', function (e) { e.listnerEvent = "focus"; bp_config.dealEvent(e); }, true); }

控制台拦截

通过对 控制台 console 原型进行改写 ,可以获取控制台输出 // 伪代码 重写console.error 其他类型 const oldError = console.error; const oldLog = console.log; console.error = function () { setTimeout(function () { //进行上报 }, 0);
return oldError.apply(console, arguments); }; console.log = function () { setTimeout(function () { //进行上报 }, 0);
return oldLog.apply(console, arguments); };

总结

经过上面基础数据的采集,进行汇总统计后,我们可以得到如下能力

  1. 页面各阶段耗时统计: 重定向耗时、DNS查询耗时、TCP链接耗时、HTTP请求耗时、解析DOM树耗时、DOM Ready时间、onload时间等。
  2. 错误统计: 精细化分析每一个报错问题,源码定位。通过探针监控和上报线上环境的报错,以及一些自定义异常,可以准确定位到代码的问题所在。同时能够看到每一个报错的变化趋势。
  3. 性能分析: 分析页面和接口性能,加载耗时,成功率。探针对页面的加载性能进行分析,直观反映在报表之上。也对接口的性能进行了分析,如:耗时、成功率等。
  4. 全链路性能监控: 结合操作日志和调用链实现全链路性能监控视图, 能够全面准确确定系统性能瓶颈
  5. 资源监测分析: 监控静态资源加载情况,统计分析静态资源加载失败的情况,分析前端白屏

从而很方便解决如下场景的问题:

  1. 系统上线后,发现访问页面速度 相对比前老系统 变慢了。 客户要求解决,否则极其影响用户体验。 有了前端监控后我们可以按如下进行排查

● 如果页面加载的首次渲染时间偏高,或DNS查询、TCP连接和SSL建连耗时偏高,表示页面打开缓慢是由客户网络原因造成的。 ● 如果页面 DOM Ready耗时偏高,或页面加载的请求响应和内容传输耗时偏高,表示页面打开缓慢的原因可能是API请求缓慢。 那可以在API请求列表 里查看整体耗时和微服务调用链的调用时序图,查看链路上哪部分内容耗时较长,继而定位问题。

  1. 系统上线后,发现系统不稳定 ,界面操作有报错现象。 前端监控后我们可以按如下进行排查,复现

● 查看监控 错误列表,针对前端上报的错误进行排查 。 ● 最好的辅助排查方式就是复现,前端监控把页面上发生的各个事件节点定义为用户行为,包括页面加载、路由跳转、页面单击、API请求、控制台输出等信息,按照时间顺序将用户行为串联起来就是用户的行为链路。通过出错时的行为回溯可以帮助开发者复现问题

  1. 随着页面越来越多,功能越来越复杂,主动发现系统问题

● 通过实时前端监控,及时发现上报错错误、访问慢接口 主动及时优化系统,

后续

上面是前端监控系统前端技术的研究,前端监控系统中前端探针js正在开发,开发中肯定会遇到各种坑,后续文章会记录下各种坑。同时欢迎各位大佬指正