前语

怎么快速定位线上bug,是大都开发者都会遇到的难题

web-see前端监控方案,供给了 前端录屏+定位源码 办法,让bug无处藏身

这是前端监控的第二篇,该篇讲解怎么完成过错复原功用,第一篇 从0到1搭建前端监控平台,面试必备的亮点项目(已开源) 没有看过的小伙伴,主张先了解下

最终作用

在监控后台,经过报错信息列表,能够检查详细报错的源码,以及报错时的录屏回放

作用演示:

前端录屏+定位源码,帮你快速定位线上bug

录屏记载了用户的一切操作,红色的线代表了鼠标的移动轨道

定位源码

前端项目发布上线,代码一般都会进行紧缩、混杂、甚至加密,当线上代码报错时,很难定位到详细的源码

SourceMap 完美处理了代码反解的问题,项目在打包时,除了生成最终 XXX.js 文件外,还会额外生成一个 XXX.js.map 的文件

.map 文件里包含了原始代码及其映射信息,能够运用它反解出报错信息的源码

SourceMap 文件

先了解下 SourceMap 的基本内容

例如 app.a2a3ceec.js 代码如下:

varadd=function(x,y){returnx+y;};
//#sourceMappingURL=app.a2a3ceec.js.map

其间 sourceMappingURL 用来阐明该文件对应的map文件

对应的 app.a2a3ceec.js.map 代码如下:

{
  version:3,//SourceMap标准版别,最新的为3
  file:"js/app.a2a3ceec.js",//转化后的文件名
  sourceRoot:"",//转化前的文件地点目录,假如与转化前的文件在同一目录,该项为空
  sources:[ //转化前的文件,该项是一个数组,表明可能存在多个文件合并
    "webpack://web-see-demo/./src/App.vue",
    "webpack://web-see-demo/./src/main.js"
  ],
  names:[],//转化前的一切变量名和特点名
  sourcesContent:[//原始文件内容
  "constadd=(x,y)=>{nreturnx+y;n}"
  ],
  // 一切映射点
  mappings:"AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ;IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC;AACb,CAAC,CAAA"
}

其间 sources 和 sourcesContent 是要害字段,下文的复原示例中将用到

source-map-js 库

代码复原,这儿首要运用 source-map-js 库,下面介绍下怎么运用

示例代码:

import sourceMap from 'source-map-js';
/**
* findCodeBySourceMap用于获取map文件对应的源代码
* @param { string } fileName .map文件名称
* @param { number } line 产生过错的行号
* @param { number } column 产生过错的列号
* @param { function } 回调函数,返回对应的源码
*/
const findCodeBySourceMap = async ({ fileName, line, column }, callback) => {
  // loadSourceMap 用于获取服务器上 .map 的文件内容
  let sourceData = await loadSourceMap(fileName);
  let { sourcesContent, sources } = sourceData;
  // SourceMapConsumer实例表明一个已解析的源映射
  // 能够经过在生成的源中给它一个文件方位来查询有关原始文件方位的信息
  let consumer = await new sourceMap.SourceMapConsumer(sourceData);
  // 输入过错的产生行和列,能够得到源码对应原始文件、行和列信息
  let result = consumer.originalPositionFor({
    line: Number(line),
    column: Number(column)
  });
  // 从sourcesContent得到详细的源码信息
  let code = sourcesContent[sources.indexOf(result.source)];
  ……
  callback(code)

本小节的代码仓库

source-map 的复原流程:

1、从服务器获取指定.map 的文件内容

2、new 一个 SourceMapConsumer 的实例,表明一个已解析的源映射,给它一个文件方位来查询有关原始文件方位的信息

3、输入报错产生的行和列,能够得到源码对应原始文件名、行和列信息

4、从源文件的 sourcesContent 字段中,获取对应的源码信息

接下来的重点就变为:怎么获取报错产生的原始文件名、行和列信息

error-stack-parser 库

经过第一篇文章的介绍,咱们知道能够经过多种办法来捕获报错

比方 error事情、unhandledrejection事情、vue 中经过Vue.config.errorHander、react中经过componentDidCatch

为了消除各浏览器的差异,运用 error-stack-parser 库来提取给定过错的原始文件名、行和列信息

示例代码:

import ErrorStackParser from 'error-stack-parser';
ErrorStackParser.parse(new Error('BOOM'));
// 返回值 StackFrame 仓库列表
[
    StackFrame({functionName: 'foo', args: [], fileName: 'path/to/file.js', lineNumber: 35, columnNumber: 79, isNative: false, isEval: false}),
    StackFrame({functionName: 'Bar', fileName: 'https://cdn.somewherefast.com/utils.min.js', lineNumber: 1, columnNumber: 832, isNative: false, isEval: false, isConstructor: true}),
    StackFrame(... and so on ...)
]

这儿简单阐明下 JS 仓库列表

仓库示例:

function c() {
  try {
    var bar = baz;
    throw new Error()
  } catch (e) {
    console.log(e.stack);
  }
}
function b() {
  c();
}
function a() {
  b();
}
a();

上述代码中会在履行到 c 函数的时分报错,调用栈为 a -> b -> c,如下图所示:

前端录屏+定位源码,帮你快速定位线上bug

一般咱们只需求定位到 c 函数的仓库信息,所以运用 error-stack-parser 库的时分,只取 StackFrame 数组中的第一个元素

最终代码:

import ErrorStackParser from 'error-stack-parser';
// 取StackFrame数组中的第一个元素
let stackFrame = ErrorStackParser.parse(error)[0];
// 获取对应的原始文件名、行和列信息,并上报
let { fileName, columnNumber, lineNumber } = stackFrame;

示例演示

下载 web-see-demo 安装并运行

1)点击 js过错 按钮,会履行 HomeView.vue 文件中的 codeErr 办法

codeErr的源码为:

前端录屏+定位源码,帮你快速定位线上bug

2)Vue.config.errorHander中捕获到报错信息为:

前端录屏+定位源码,帮你快速定位线上bug

3)运用 ErrorStackParser.parse 解析后的stackFrame为:

前端录屏+定位源码,帮你快速定位线上bug

4)经过 consumer.originalPositionFor 复原后的 result 结果为:

前端录屏+定位源码,帮你快速定位线上bug

5)最终拿到的源码:

前端录屏+定位源码,帮你快速定位线上bug

流程总结

前端录屏+定位源码,帮你快速定位线上bug

如上图所示,定位源码流程总结:

1、项目中引入监控 SDK,打包后将js文件发布到服务器上

2、将 .map 文件放到指定的地址,统一存储

3、当线上代码报错时,运用 error-stack-parser 获取详细原始文件名、行和列信息,并上报

4、运用 source-map 从 .map 文件中得到对应的源码并展示

前端录屏

web-see 监控经过rrweb供给了前端录屏的功用

rrweb 运用

先介绍下在vue中怎么运用

录制示例:

import { record } from 'rrweb';
// events存储录屏信息
let events = [];
// record 用于记载`DOM`中的一切变更
rrweb.record({
  emit(event, isCheckout) {
    // isCheckout 是一个标识,告知你从头制造了快照
    if (isCheckout) {
      events.push([]);
    }
    events.push(event);
  },
  recordCanvas: true, // 记载 canvas 内容
  checkoutEveryNms: 10 * 1000, // 每10s从头制造快照
  checkoutEveryNth: 200, // 每 200 个 event 从头制造快照
});

播映示例:

<template>
  <div ref='player'>
  </div>
</template>
<script>
import rrwebPlayer from 'rrweb-player';
import 'rrweb-player/dist/style.css';
export default {
   mounted() {
     // 将记载的变更依照对应的时刻逐个重放
     new rrwebPlayer(
        {
          target: this.$refs.player, // 回放所需求的HTML元素
          data: { events }
        },
        {
          UNSAFE_replayCanvas: true // 回放 canvas 内容
        }
     )
   }
}
</script>

rrweb 原理浅析

rrweb 首要由 rrwebrrweb-playerrrweb-snapshot 三个库组成:

1)rrweb:供给了 record 和 replay 两个办法;record 办法用来记载页面上 DOM 的改变,replay 办法支撑根据时刻戳去复原 DOM 的改变

2)rrweb-player:根据 svelte 模板完成,为 rrweb 供给了回放的 GUI 东西,支撑暂停、倍速播映、拖拽时刻轴等功用。内部调用了 rrweb 的供给的 replay 等办法

3)rrweb-snapshot:包括 snapshot 和 rebuilding 两大特性,snapshot 用来序列化 DOM 为增量快照,rebuilding 负责将增量快照复原为 DOM

rrweb 整体流程:

1)rrweb 在录制时会首要进行首屏 DOM 快照,遍历整个页面的 DOM 树,转化为 JSON 结构数据,运用增量快照的处理办法,经过mutationObserver获取DOM增量改变,同步转化为 JSON 数据进行存储

2)整个录制的过程会生成 unique id,来确认增量数据所对应的 DOM 节点,经过 timestamp 确保回放次序。

3) 回放时,会创立一个 iframe 作为承载事情回放的容器,针对首屏DOM快照进行重建,在遍历JSON的一起,根据序列化后的节点数据构建出实际的 DOM 节点

4)rrweb 能够监听的用户行为包括:鼠标移动,鼠标交互,页面滚动,视窗改变、用户输入等,经过增加相应的监听事情来完成

紧缩数据

假如一直录屏,数据量是巨大的

实测下来,录制10s的时长,数据巨细约为 8M 左右(页面的不同复杂度、用户不同操作的频率都会造成巨细不一样)

数据假如不经过紧缩,直接传给后端,面对很多的用户,需求十分高的带宽做支撑。还好,rrweb官方供给了数据紧缩函数

根据 packFn 的单数据紧缩,在录制时能够作为packFn传入

rrweb.record({
  emit(event) {},
  packFn: rrweb.pack,
});

回放时,需求传入 rrweb.unpack 作为unpackFn传入

const replayer = new rrweb.Replayer(events, {
  unpackFn: rrweb.unpack,
});

可是官方供给的紧缩办法,是对每个 event 数据独自进行紧缩,紧缩比不高。实测下来,紧缩比在70%左右,比方本来 8M 的数据,紧缩后为 2.4M 左右

官方愈加推荐完成将多个 event 批量一次性紧缩,这样紧缩作用更好

web-see 内部运用 pako.js、js-base64 相结合的紧缩办法,实测下来,紧缩比为 85% 以上,本来 8M 的数据,紧缩后为 1.2M 左右

紧缩代码示例:

import pako from 'pako';
import { Base64 } from 'js-base64';
// 紧缩
export function zip(data) {
  if (!data) return data;
  // 判别数据是否需求转为JSON
  const dataJson = typeof data !== 'string' && typeof data !== 'number' ? JSON.stringify(data) : data;
  // 运用Base64.encode处理字符编码,兼容中文
  const str = Base64.encode(dataJson);
  let binaryString = pako.gzip(str);
  let arr = Array.from(binaryString);
  let s = '';
  arr.forEach((item) => {
    s += String.fromCharCode(item);
  });
  return Base64.btoa(s);
}

解压代码示例:

import { Base64 } from 'js-base64';
import pako from 'pako';
// 解压
export function unzip(b64Data) {
  let strData = Base64.atob(b64Data);
  let charData = strData.split('').map(function (x) {
    return x.charCodeAt(0);
  });
  let binData = new Uint8Array(charData);
  let data = pako.ungzip(binData);
  // ↓切片处理数据,避免内存溢出报错↓
  let str = '';
  const chunk = 8 * 1024;
  let i;
  for (i = 0; i < data.length / chunk; i++) {
    str += String.fromCharCode.apply(null, data.slice(i * chunk, (i + 1) * chunk));
  }
  str += String.fromCharCode.apply(null, data.slice(i * chunk));
  // ↑切片处理数据,避免内存溢出报错↑
  const unzipStr = Base64.decode(str);
  let result = '';
  // 对象或数组进行JSON转化
  try {
    result = JSON.parse(unzipStr);
  } catch (error) {
    if (/Unexpected token o in JSON at position 0/.test(error)) {
      // 假如没有转化成功,代表值为基本数据,直接赋值
      result = unzipStr;
    }
  }
  return result;
}

何时上报录屏数据

一般关注的是,页面报错的时分用户做了哪些操作,所以现在只把报错前10s的录屏上签到服务端

怎么只上报报错时的录屏信息呢 ?

1)window上设置 hasError、recordScreenId 变量,hasError用来判别某段时刻代码是否报错;recordScreenId 用来记载此次录屏的id

2)当页面宣布报错需求上报时,判别是否敞开了录屏,假如敞开了,将 hasError 设为 true,一起将 window 上的 recordScreenId,存储到此次上报信息的 data 中

3)rrweb 设置10s从头制造快照的频率,每次重置录屏时,判别 hasError 是否为 true(即这段时刻内是否产生报错),有的话将这次的录屏信息上报,并重置录屏信息和 recordScreenId,作为下次录屏运用

4)后台报错列表,从本次报错报的data中取出 recordScreenId 来播映录屏

录屏的代码示例:

handleScreen() {
 try {
  // 存储录屏信息
  let events = [];
  record({
    emit(event, isCheckout) {
      if (isCheckout) {
        // 此段时刻内产生过错,上报录屏信息
        if (_support.hasError) {
          let recordScreenId = _support.recordScreenId;
          // 重置recordScreenId,作为下次运用
          _support.recordScreenId = generateUUID();
          transportData.send({
            type: EVENTTYPES.RECORDSCREEN,
            recordScreenId,
            time: getTimestamp(),
            status: STATUS_CODE.OK,
            events: zip(events)
          });
          events = [];
          _support.hasError = false;
        } else {
          // 不上报,清空录屏
          events = [];
          _support.recordScreenId = generateUUID();
        }
      }
      events.push(event);
    },
    recordCanvas: true,
    // 默认每10s从头制造快照
    checkoutEveryNms: 1000 * options.recordScreentime
  });

遗留问题,在线求解

依照官方的 canvas 配置,验证下来,rrweb 还是不支撑 canvas 的录制,比方运用 echarts 画图,图形区域的录屏显示是空白的

官方配置 如下:

前端录屏+定位源码,帮你快速定位线上bug

测试demo 如下:

前端录屏+定位源码,帮你快速定位线上bug

录屏回放,图形这块区域是空白的:

前端录屏+定位源码,帮你快速定位线上bug

这块有研讨的小伙们麻烦指导下,问题出在哪里,感谢

总结

前端录屏+定位源码,是现在比较盛行的过错复原办法,关于快速定位线上bug大有裨益

这两篇文章仅仅关于前端监控的入门级介绍,其间能够深挖的点还有很多,欢迎小伙们多多讨论与交流

最后推荐一篇阿里前端监控负责人的专题演讲:《大前端时代前端监控的最佳实践》,了解下前端监控的天花板有多高

天冷了,别忘了穿秋裤撒

前端录屏+定位源码,帮你快速定位线上bug