likes
comments
collection
share

🏄🏻你知道怎么捕获 js 报错前的用户行为吗

作者站长头像
站长
· 阅读数 20

抛出问题

我们知道线上环境复杂多变,不像本地测试的时候那样顺利,经常会有各种杂七杂八的问题,要想主动高效的定位这些异常,就得接入监控系统啦。作为前端的我们应该或多或少都有所了解,大概就是监听各种 error 事件,然后整理下数据并上报,比如下面这样👇🏻:

const handleError = e => {
    // ...
    report();
};
const handleRejection = e => {
    // ...
    report();
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleRejection);

但是有时候这些错误并不是那么直观,也不好复现🧐,所以要是我们能够捕获到异常发生时的一些上下文信息就好了。🤔。。。那,这个上下文是指啥呢,让我们先看看下面这张图(参考自sentry): 🏄🏻你知道怎么捕获 js 报错前的用户行为吗 从上图中可以看出在发生报错之前,用户进行了两次页面跳转,两次 xhr 请求,并且进行了几次点击操作。于是乎,我们就可以脑补出用户大概的一个行为路径了,这样也许就能复现 bug 了,听起来是不是还有点意思😬。所以本篇文章主要就是讲解一下这个东西是怎么实现的。

具体思路

收集什么

因为刚开始很容易一头雾水,所以我们先把上面的示意图进行一个简单的转化,它的本质就是个普通的数组,只不过每条数据的类型会有所不同,就像下面这样👇🏻:

[
    { "type": "dom", "timestamp": "2023-05-27T06:37:41.307522Z", "level": "info", "message": "a.product-thumbnail-item", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:41.692138Z", "level": "info", "message": null, "category": "navigation", "data": { "from": "/shop/", "to": "/shop/products/plant-mood-planter/" } },
    { "type": "dom", "timestamp": "2023-05-27T06:37:42.076753Z", "level": "info", "message": "button.add-to-cart", "category": "ui.click", "data": null },
    { "type": "http", "timestamp": "2023-05-27T06:37:42.461368Z", "level": "info", "message": null, "category": "xhr", "data": { "method": "POST", "status_code": 200, "url": "/api/0/cart/" } },
    { "type": "dom", "timestamp": "2023-05-27T06:37:42.845984Z", "level": "info", "message": "a#view-cart", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:43.230599Z", "level": "info", "message": null, "category": "navigation", "data": { "from": "/shop/products/plant-mood-planter/","to": "/shop/checkout/" } },
    { "type": "dom", "timestamp": "2023-05-27T06:37:43.615215Z", "level": "info", "message": "input#zipcode", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:43.999830Z", "level": "info", "message": "button#calculate-shipping", "category": "ui.click", "data": null },
    { "type": "http", "timestamp": "2023-05-27T06:37:44.384445Z", "level": "info", "message": null, "category": "xhr", "data": { "method": "POST", "status_code": 200, "url": "/api/0/cart/update-shipping/" } },
    { "type": "dom", "timestamp": "2023-05-27T06:37:44.769061Z", "level": "info", "message": "input#card-name", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:45.153677Z", "level": "info", "message": "input#card-number", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:45.538292Z", "level": "info", "message": "input#card-cvv", "category": "ui.click", "data": null },
    { "type": "dom", "timestamp": "2023-05-27T06:37:45.922907Z", "level": "info", "message": "input#submit", "category": "ui.click", "data": null }
]

简单抽离一下它的基本结构,大致如下:

interface Breadcrumb {
  type?: string; // 类型,比如 dom,http
  category?: string; // 具体分类,比如 dom 下面的 click 和 navigation;http 中的 xhr 和 fetch
  message?: string;
  data?: { [key: string]: any };
  level?: string;
  timestamp?: number;
}

有同学看到 Breadcrumb 这个单词,心想说这不是面包屑的吗,怎么取这个名字。其实它本意就是有迹可循的意思,所以用在这里还是很恰当的。(🤯面包屑的起源:从前有两个孩子在森林中走迷了路,为了找回家,他们在路上散落着面包屑,用以标记自己的行走路径,最终成功回到家中。)

可以看到,每条数据大体会有类型、数据、时间戳等几个重要的部分组成,显然不同的类型会对应不同的数据,所以我们只需要知道有哪些类型并记录相关信息即可。那怎么确定有哪些类型呢? 想想我们如果要知道用户报错前的一些信息,肯定不能在报错的时候才去记录,那时候已经晚了,并且我们也不确定什么时候会报错。所以...所以在一开始就得进行收集😅,如果报错了就把一路以来收集到的信息上报,如果没报错那就不管,这是要先明确的一点。 然后就是确定要收集哪些类型以及怎么收集。这个乍一看也没什么思绪,好像还得上录屏。事实上没这么麻烦,我们可以先想想一般什么情况下做什么操作会导致 js 报错🤔。经过几秒钟短暂的思考后,大概可以罗列出以下几种情况:

  • 页面跳转
  • 接口调用后
  • 点击某个按钮
  • 键盘按下时
  • console 打印的信息
  • 定时器
  • ...(用户行为 && 浏览器行为 && 控制台行为)

而其中导致报错概率最大的主要就是发送请求和点击事件,所以接下来会以这两种情况为例子来看看我们是怎么进行收集的🥳。

初始工作

在此之前,我们先定义一个全局变量,顺便简化一下 interface,就像下面这样👇🏻:

interface Breadcrumb {
  type?: string; // 类型,比如 fetch、click
  data?: { [key: string]: any }; // 类型对应的数据
  timestamp?: number; // 触发时间
}

const breadcrumbs = []; // 所有行为路径

function addBreadcrumb(breadcrumb) {
    breadcrumbs.push(breadcrumb);
}

之后想要收集的时候只要调用 addBreadcrumb 方法往 breadcrumbspush 一条条记录就好啦。

收集 fetch 请求

这里我们就先以收集请求为例进行解释说明。可是接口请求那么多,要是在每个接口都手动加上收集的逻辑会很繁琐,所以就需要自动的对每个请求进行处理(有点类似手动埋点和全自动埋点)。那怎么进行全量处理并且无感知嘞,就是函数劫持啦(也可以叫 AOP,面向切面编程),前端最常用的魔改手段之一(此招一出,手动变自动)。通常发送请求有 xhr 和 fetch 两种 api,这里我们以 fetch 举例来看看基本的函数劫持写法:

const _fetch = window.fetch; // 缓存原来的方法
window.fetch = function(url, options) {
    // 发送请求前可以做点事
    const result =  _fetch.call(this, url, options); // 执行原来的请求逻辑
    // 发送请求后可以做点事
    return result;
}

想想我们发送请求的时候需要记录什么信息呢🤔?好像主要就几个:接口地址、请求方法、状态码和请求时间?那就先简单记录一下它们,就像下面这样👇🏻:

const _fetch = window.fetch;
window.fetch = function(url, options) {
    const breadcrumb = {
        type: 'fetch',
        data: {
            url,
            method: options.method || 'GET',
            startTimestamp: Date.now()
        }
    };

    return _fetch.call(this, url, options).then(response => {
        breadcrumb.data.response = response;
        breadcrumb.data.endTimestamp = Date.now();
        addBreadcrumb(breadcrumb);
        return response;
    }, error => {
        breadcrumb.data.error = error;
        breadcrumb.data.endTimestamp = Date.now();
        addBreadcrumb(breadcrumb);
        throw error;
    });
}

注意到上面的代码中,我们是在请求返回的时候才添加一条 fetch 面包屑数据,这是因为我们需要展示接口的状态码或错误码,以及如果我们希望增加一些自定义参数,比如接口中一般会有个 logid 方便后端排查问题,也可以将其带上,而这些数据在发送请求前是木有的。 此外我们还注意到这里顺便记录了请求发起和返回的的时间戳,但这并不是添加面包屑的时间戳(虽然和请求返回的时间差不多),并且面包屑的时间戳是每条记录都有的,所以可以把时间戳的逻辑放在通用方法 addBreadcrumb 里面,就像下面这样👇🏻:

const breadcrumbs = [];

function addBreadcrumb(breadcrumb) {
    breadcrumb.timestamp || (breadcrumb.timestamp = Date.now());
    breadcrumbs.push(breadcrumb);
}

收集点击事件

有了上面的基本实践,接下来我们说说如何记录点击事件,看起来也是直接劫持魔改 EventTarget.prototype.addEventListener 这个 api🤔?这当然是没问题的。不过有个更方便的方法,就是利用点击事件的特殊性,我们直接在 document 上进行全局监听即可,一行代码就能轻松搞定,比如这样:

document.addEventListener('click', e => {
    console.log(e.target.tagName);
});

这样一来所有点击事件都会冒泡到 document 上,也就是所有点击事件都会触发上面那段代码,接下来只要在回调里面加上需要的面包屑逻辑即可。那点击事件需要记录什么信息呢?好像只需要知道点击哪个元素就可以了🤔,没错,确实是这样。那怎么标识这个元素呢,除了基本的标签名外,我们还需要去获取点击元素的 class 样式名(当然 id 和属性选择器也是要的,这里就是举个例子),就像下面这样:

document.addEventListener('click', e => {
    const { tagName, className } = e.target;
    const breadcrumb = {
        type: 'click',
        data: {
            selector: `${tagName.toLowerCase()}.${className.split(' ').join('.')}` // 点击元素的格式大概长这样:'tagName#id.class',比如 'button.submit'
        }
    };
    breadcrumbs.push(breadcrumb);
});

这时候就会出现两个问题,一个问题是有的元素没有 class 或者同一个 class 的元素有很多个,那也就无法定位出具体是哪个元素被点击了,为此我们需要把该元素的父元素也记录下来,然后拼成下面这个样子:

body > div#app > div.box > ul > li.row.active

这样一来就基本能定位到是哪个元素了,不过需要一个小小的向上递归的过程🤯。 另一个问题是如果这样做会不会造成数据冗余并且消耗性能。em。。。确实如此,不过我们只需要简单限制下向上递归的次数就行了,比如四五次就 OK 了,不用一直遍历到根元素,这里就简单贴个代码实现(直接 copy 下面的代码到浏览器运行就能看到效果😄):

function getXpath(ele) {
  const pathArr = [];

  function helper(ele, depth = 5) {
    if (!ele || depth < 1) return;
    const { tagName, className } = ele;
    const selector = `${tagName.toLowerCase()}.${className.split(' ').join('.')}`;
    pathArr.push(selector);

    helper(ele.parentNode, depth - 1);
  }
  
  try {
    helper(ele);
    return pathArr.reverse().join(' > ');
  } catch(e) {
    return '<unknown>'
  }
}
// 如果不想这么麻烦,也可以直接用 element.outerHTML 来表示,舍弃递归

不过这样还是会有问题,比如我们点击了某个按钮,按钮自身阻止了冒泡怎么办,我们就捕获不到这个按钮的点击事件了。要解决这个问题很简单,就是将 addEventListener 的第三个参数设置为 true 就行了,就像下面这样👇🏻:

document.addEventListener('click', e => {}, true);

就这样简单的一个操作我们就把冒泡的过程改成了捕获,也就是所有点击事件都会先触发我们的回调。 此外我们还可以做一些优化,比如多次点击可以简单节个流,包个 throttle 函数即可;点击空白处或者点击了某个元素但不触发事件的(比如纯文本)也不进行处理,但这个还是比较难办的,即便可以通过 getEventListeners(ele) 这个方法来判断某个元素有没有绑定事件,但是这并不好用,比如我们点击按钮内部的元素也可以触发事件,但是内部元素并没有绑定事件。

小知识:事件的传播通常有三个阶段:捕获阶段、目标阶段、冒泡阶段。在这三个阶段中,事件传播时所携带的信息都是相同的,也就是 event 是相同的。

如果你用的是劫持的方式来处理点击事件你就要注意,同一个事件可能会被出发多次,所以需要用一个变量保存最近一次触发的事件 lastCapturedEvent,然后和当前 event 做对比,如果一样就说明是同样的事件源,可以跳过。 至此,我们已经简单过了一下两种面包屑的实现,至于其他情况写法大同小异,都是对相应的 api 进行劫持,只是对应的数据不同罢了,比如页面跳转的 data 就是 { from, to } 即可。最后我们只需要在发生报错的时候,把当前的 breadcrumbs 一起上报并做个简单的可视化就行了🥳。

一些疑问

  • 我可以对面包屑做一些自定义操作吗?当然,我们只需要在 push 或者上报的时候加个钩子即可,就像下面这样👇🏻:

    const breadcrumbs = [];
    const beforeAddBreadcrumb = breadcrumb => {
        // do something
    }
    function addBreadcrumb(breadcrumb) {
        breadcrumb.timestamp || (breadcrumb.timestamp = Date.now())
        beforeAddBreadcrumb(breadcrumb);
        breadcrumbs.push(breadcrumb);
    }
    

    通常在一些需要过滤敏感数据的情况下,beforeAddBreadcrumb 这个钩子就显得尤为重要,比如海外业务。

  • 面包屑数据量不会太大吗? em。。。确实是会这样,毕竟我们从头记到尾,所以可以控制一下 breadcrumbs 的长度,比如控制在 20 条以内,超出了就把头部元素删了,只留下最近的即可;另外还可以控制一下点击元素的 selector 的长度,当 xpath 过长时也不继续递归了。

  • 我。。。可以用录屏吗😳?当然,录屏相对面包屑的脑补画面来说肯定更加直观,但是录屏的成本和大小都远高于面包屑,有条件当然是允许录屏的(比如 sentry 会通过 rrweb 提供这个功能)。不过录屏一般用在保险、审核这种需要留存记录和证据的地方,对于监控来说倒不是刚需。

小结

通过本文的简单介绍,想必你对怎么捕获报错发生前的行为应该有所了解😁。当然了,这里还得再强调一下,这个面包屑只是当你对报错没有什么头绪的时候提供一个有迹可循的思路而已,它不是必需品,仅仅是个辅助。好啦,本篇文章就写到这里,有什么问题欢迎点赞评论留言,我们下期再见,拜拜👋🏻。

下期预告:不想脑补想要实现录屏的同学,我会在下期写个简单 demo 实现它,敬请期待。或者你有什么好的立意也可以在评论区留言喔,🥳然后。。。安排~

转载自:https://juejin.cn/post/7240833613357416507
评论
请登录