跳转至

11 用户行为指标:如何有效监听用户交互行为?

你好,我是三桥。

这节课,我们继续学习前端全链路的实现过程,这次,我们聚焦在用户行为指标上。

广义来讲,用户行为是指在Web前端技术实现的所有应用或产品下,用户与它们的交互情况。通过收集、存储和分析用户线上的行为、交互以及用户属性,能够帮助我们分析和排查不少问题。

为什么这部分这么重要呢?我来跟你分享一个故事。

当时的教育产品里有一个功能是制作海报,全制作流程都是由前端技术来驱动和完成,只有业务数据是后端提供的,海报系统已经实现了全链路监控。

有一天,有位学生上完课后无法在系统上打卡生成海报,点击“一键生成”按钮后没有反应。老师也不知道如何解决,就直接反馈给了产品和前端同学。

接到反馈后,我们观察了海报系统的全链路日志,发现没有发生任何故障,全局链路日志也比较正常。接着,我们通过这个学生的ID把对应的链路日志查了出来,发现日志也比较正常。

正当我们觉得这个用户现象很奇怪的时候,团队的一个小伙伴就从用户行为日志里面发现了一个现象,这个用户并不在海报用户组和配置里面。那有没有可能就是这个原因导致的不能生成海报呢?

结果,还真的是海报用户组的原因。这位学生并不在“可打卡”的用户组里面,虽然学生能进入制作海报页面,但并没有制作海报的权限。

最后,我们在后台更新配置后,这位学生就能生成海报了。

那为什么代码没有报错或者出现异常信息呢?因为这是一个正常的产品流程,链路日志也不会因为一些流程分支逻辑而记录日志。

从这个故事中,我们得出一个结论,就是用户反馈的问题并不一定是代码问题,可能是产品业务逻辑的问题,也可能是系统配置导致的。但是,只要是个别用户反馈的问题,大概率都能找到原因。

这次找到问题完全依靠的是链路日志中的用户行为日志。虽然全链路设计的核心目的是发现问题,但有时候正常的日志也能帮助我们发现一些隐藏性的业务问题,比如例子中的配置问题。

我认为用户交互行为日志在前端全链路中起了辅助作用,主要体现在两个方面。

首先,我们可以通过交互行为日志,把一些未上报的链路日志连接起来,例如一些正常请求接口日志。有了这些信息,每当有错误日志上报时,我们就可以通过交互行为日志追溯上下文,排查错误日志是否是由其它问题引起的。

第二,前端同学可以通过链路日志和行为日志来辅助排查一些有特殊性的个例问题。也就是说,我们在遇到问题后,首要的工作不是重现问题,而是查阅日志。

哪些行为需要记录?

好了,既然用户行为记录有这么多的帮助,那么对于一个前端Web页面来说,有哪些行为需要记录下来呢?

先强调一点,前端全链路中的记录用户行为就是为了辅助前端同学排查问题,而非提前发现问题。

我总结出一共有5种行为类型,作为全链路里的用户行为日志,它们分别是操作类型、请求类型、全局错误、框架内置错误以及自定义上报错误。

  • 操作类型,也就是用户的操作行为。现在的前端页面的输入方式种类非常多,不仅有点击、触屏、键盘等传统的输入方式,还有手势、人脸识别、AI这种非接触式的输入方式。操作类型指的就是这些输入方式。
  • 请求类型,也就是记录使用Fetch这类发起接口请求的状态。我们之所以要强调记录请求类型,是因为随着业务的深入发展,前端依赖接口以及画像数据的情况会越来越多,记录它是为了追踪接口的输入输出,以便辅助定位问题。另外,请求接口是一个输入输出的异步过程,所以一次请求,一定包含请求前和请求后两条行为记录。
  • 全局错误。我们上节课学习了捕获全局Error事件的方法,它能捕捉脚本错误和资源错误。除了全局Error事件,前端还存在一种异步异常事件,它就是基于Promise的异步事件,其中reject回调事件通常都会被作为异常处理。
  • 框架内置错误。实际上就是以React或Vue技术框架实现的前端项目。通常这类框架都有内置错误捕获事件机制,对于我们监听基于框架实现的业务逻辑异常错误有很大的帮助。
  • 自定义上报类型。这种类型是为了业务异常而存在。有时候有些业务逻辑场景就是不能往下走的,例如本节课的例子,我们可以在不满足用户群组的条件下,上报一条自定义日志,这样就能减少排查问题所耗费的时间了。

图片

记录用户行为的实现方法

我们先来学习封装记录行为日志的方法。既然是记录行为,那行为就有先后顺序,所以我们的方案是采用数组的形式存储,以先进先出的算法逻辑来控制存储量的上限。

依据这样的设计方案,我们定义了两个字段。一个是用于存储数据的 breadcrumb 字段,另一个就是最大存储量的 maxBreadcrumb 字段。

// src/baseTrace.ts

export class BaseTrace implements BaseTraceInterface {
    // 记录用户行为
  public breadcrumb: TraceBreadcrumbs = []

  // 最大存储用户行为
  public maxBreadcrumb = 10
}

还记得在之前我们就设计好了用户行为的数据类型吗?没错,它就是 TraceBreadcrumbs 类型。接下来,我们就以 TraceBreadcrumbs 类型的数据结构存储行为数据,同时实现一个先进先出的存储函数。

具体的代码实现参考如下。

// src/baseTrace.ts

public saveBreadcrumb(data: TraceBreadcrumbs) {
    this.breadcrumb.push(data)
  if (this.breadcrumb.length > this.maxBreadcrumb) {
    this.breadcrumb.shift()
  }
}

代码的逻辑是这样的,首先先把传入函数的 data 数据通过 push 传入 breadcrumb 数组。然后再判断 breadcrumb 数组的长度是否超过最大存储量。如果超过,就利用数组的 shift 函数,把数组的第一条记录移除。

就这样,我们就把记录用户行为的逻辑实现完了,你可能惊呼就这么简单?是的,就是这么简单。在这里,我们的实现目的就是存储数据,尽量不做额外的逻辑。至于每一种类型的行为数据是怎么定义出来的,我们放在上层的行为动作的逻辑里就好。

在事件中插入交互行为日志

我们再来回顾一下前面提到的 TraceBreadcrumbs 数据类型,这种类型我们在前面的课程中提到过,它就是 TraceAction 的数组类型。

每个 TraceAction 数据类型,包含有5个必填属性字段,分别是 nameleveltimetypecategory 。另外还需要增加4个可选属性字段,分别是 messagerequestresponsestack 。再次回顾一下这个数据类型代码。

type TraceBaseAction = {
  // 动作名称
  name: string
  // 动作参数
  level: TraceDataSeverity
  // 动作时间
  time: string
  // 日志类型
  type: BreadcrumbTypes
  // 行为分类
  category: BreadcrumbsCategorys
}

// 行为日志
type TraceAction = TraceBaseAction & {
  // 行为动作相关的信息,可以是DOM,可以是错误信息,可以是自定义信息
  message?: string
  // 请求参数
  request?: any
  // 请求结果内容
  response?: any
  // 错误堆栈信息
  stack?: string
}

现在,我们就来实现操作类型、请求类型以及全局错误这三种类型下的交互行为日志存储。

操作类型

操作类型的日志,我们以点击事件为例来实现记录点击交互行为。

在前端全链路SDK里,我们实现一个新函数 onGlobalClick。函数内的逻辑是监听全局点击事件,并在事件内实现点击行为的记录。具体的实现代码参考如下。

public onGlobalClick() {
  const _t = this
  window.addEventListener('click', (event) => {
    const target = event.target as HTMLElement
    const innerHTML = target.innerHTML
    const bc: TraceAction = {
      name: 'click',
      level: TraceDataSeverity.Normal,
      type: BreadcrumbTypes.CLICK,
      category: BreadcrumbsCategorys.User,
      message: innerHTML,
      time: getTimestamp()
     }
    _t.saveBreadcrumb(bc)
  })
}

其中, message 属性记录的是DOM节点代码,这样做的目的是快速定位按钮区域。 category 属性就直接使用 User,代表事件是由用户触发的。type 属性则使用 CLICK,表示是点击事件。由于全局点击事件是普遍的行为,level 属性就选择Normal。

有些前端同学可能会觉得,我们只实现了点击事件的行为记录,并没有其它逻辑,难道不需要补充吗?我认为,在前端全链路的设计思路里面,点击事件一般存在两种情况。一是正常运行逻辑,二是运行时报错。

如果运行时报错没有通过try-catch捕获异常,就会触发全局Error事件。很多前端项目都会实现自定义全局点击事件,对于全链路的行为埋点逻辑,我们可以根据实际情况安排在合理的位置上。

当然了,在真实项目中 TraceAction 的属性值并非一定要按照例子中的样子去设计,前端同学可以根据实际情况调整业务逻辑。

全局Error事件的行为记录

我们知道,在每次触发全局 Error 事件,都是因为代码运行出错又没有被try-catch捕获。对于命中 Error 事件,其记录的交互行为数据跟点击事件基本相差不大,只不过是一些参数上的不同而已,例如脚本报错和资源加载错误,其level属性都采用预警级别。

具体的实现逻辑并不难,我们仍然以上节课实现的 saveError 函数作为基础,增加触发异常错误时记录到 breadcrumb 数组内的逻辑即可。

大致的代码实现参考如下。

public saveError(event: ErrorEvent) {
  const target = event.target || event.srcElement;
  const isResTarget = isResourceTarget(target as HTMLElement);
  const nodeName = (target as HTMLElement).nodeName

  if (!isResTarget) {
    // …… 省略部分代码
    this.resources.push(traceData)
    this.breadcrumb.push({
      name: event.error.name,
      type: BreadcrumbTypes.CODE_ERROR,
      category: BreadcrumbsCategorys.Exception,
      level: TraceDataSeverity.Error,
      message: event.message,
      stack: event.error.stack,
      time: getTimestamp()
    })
  } else {
    // …… 省略部分代码
    this.resources.push(traceData)
    this.breadcrumb.push({
      name: traceData.name,
      type: BreadcrumbTypes.RESOURCE,
      category: BreadcrumbsCategorys.Exception,
      level: TraceDataSeverity.Warning,
      message: event.message,
      time: getTimestamp()
    })
  }
}

我们重点说一下 level 属性的级别。

资源加载错误和脚本错误是两种不同维度的错误,资源加载错不一定会影响用户的使用过程。相反,一旦发生脚本错误,基本上都会因为某些逻辑实现得不够严谨而导致功能不可用。所以,脚本错误的 level 等级推荐使用 Error 值,而资源加载错误只需要使用Warning即可。

请求类型

接着看请求类型,它是一个非常特殊的行为类型,因为它包含了两种状态:请求前和响应结果。

在发起请求前,全链路用户行为需要记录一次行为日志,除了一些基本属性之外,应该还要包含请求方法、请求参数和请求接口。

接着,当请求响应回来后,同样还需要记录一次行为日志,也就是记录响应状态码和信息。当然,响应结果的业务数据需不需要记录,还是需要根据实际情况判断的。

具体怎么做呢?

我们要对第9节课中实现的Fetch拦截器interceptFetch进行一些优化调整。由于我们需要记录两种状态,所以我们在拦截器中新增两个回调函数参数,分别是 onBeforeonAfter。它们的作用是一个在请求前执行的函数,另一个就是响应结果后执行的函数。

具体实现的代码如下。

// src/core/fetch.ts

export type OnBeforeProps = {
  url: string
  method: 'POST' | 'GET'
  options?: RequestInit
}
export type InterceptFetchType = {
  onError: (error: OnFetchError) => void;
  onBefore: (props: OnBeforeProps) => void;
  onAfter: (result: any) => void;
}

const interceptFetch = ({
  onError,
  onBefore,
  onAfter
}: InterceptFetchType) => {

  return async (...args: any) => {
    const [url, options] = args;
    const startTime = getTimestamp()
    let res;
    try {
      onBefore && onBefore({
        url,
        method: options.method,
        options
      })
      res = await originFetch(url, options);

      onAfter && onAfter(res)
    } catch (err) {
      // …… 省略部分代码
    }
    
    // …… 省略部分代码

    return res
  }
}

好了,有了两个回调函数参数后,我们就可以定义两个回调函数,并实现行为日志的存储。

具体的代码实现如下。

window.fetch = interceptFetch({
  onError: traceSdk.onFetchError,
  onBefore: (props: OnBeforeProps) => {
      traceSdk.saveBreadcrumb({
      name: 'fetch',
      level: TraceDataSeverity.Normal,
      type: BreadcrumbTypes.FETCH,
      category: BreadcrumbsCategorys.Http,
      message: props.url,
      time: getTimestamp(),
      request: {
        method: props.method,
        url: props.url,
        options: props.options
      }
     })
   },
   onAfter: (result: any) => {
     traceSdk.saveBreadcrumb({
       name: 'fetch',
       level: TraceDataSeverity.Normal,
       type: BreadcrumbTypes.FETCH,
       category: BreadcrumbsCategorys.Http,
       message: result.status,
       time: getTimestamp(),
       response: {
         status: result.status,
         statusText: result.statusText
       }
     })
   }
 })

我在两次记录行为日志中都额外的补充了request和response属性。这两个属性并非Fetch类型上的Request和Response类型。对于链路数据的获取,我们还是遵守最少日志的原则。所以我们只记录请求方法、请求地址、请求参数、响应状态码以及相应信息这5个属性即可。

至此,我们就完成了请求类的行为日志实现逻辑。虽然这个设计是通过再封装对外一层函数实现的,而且在初始化的时候就完成了,但如果要真正实现一套完整的通用SDK,这是一个不错的逻辑解耦方法。

在交互行为日志类型中,还有框架内置类型以及自定义日志类型两种。这两种日志在真实项目中更偏业务性日志,需要根据实际情况来埋点。关于这两种类型日志的实现方法,我们将会在下节课实现自定义链路日志时一并学习。

总结

这节课我们重点学习了用户行为指标的具体实现方法。在前端全链路的实现方案里,我们一共需要记录5种用户行为类型,其中操作类型、全局错误、请求类型三种行为都属于通用全局用户行为,其余两种框架内置错误和自定义上报类型则属于业务行为类型。

用户行为在前端全链路中有着辅助性的作用。虽然它的设计并非用于提前发现问题,但它的作用不可忽视,每当需要通过链路日志查找问题的原因时,用户行为就是最好的上下文线索。

虽然我们以最简单的逻辑实现了用户行为数据的记录,但一般情况下究竟存储多少条记录才能满足排查问题的需求呢?

我认为最少10条,因为10条基本就能够覆盖一次最短操作路径。最大建议不超过100条,因为存储太多意义不大,毕竟每一次上报记录都会把当前的用户行为日志上传到服务器,会存在重复的行为日志。

另外,我们在本节课实现用户行为追踪时,请求类型的行为日志是一种被动式的收录,无论是请求前还是响应结果都存在不少用户的隐私信息,比如登录token、用户的联系电话。在真实的项目中,我们还需要把数据脱敏考虑在内。

下节课,我们继续学习如何实现提供给业务上层应用的自定义链路日志。

思考题

这节课我们学习了存储用户行为数据的方法,但是对于前端全链路来说,存储用户行为数据量大,对服务器存储空间有着较大的压力。现在给你一道思考题,我们是否可以在前端全链路SDK中增加一道开关配置,控制是否记录用户行为呢?具体的实现逻辑应该是什么样的呢?

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!

精选留言(3)
  • 苏果果 👍(0) 💬(0)

    完整源码入口: https://github.com/sankyutang/fontend-trace-geekbang-course

    2024-07-18

  • 若水清菡 👍(0) 💬(1)

    是否可以在前端全链路 SDK 中增加一道开关配置,控制是否记录用户行为呢?具体的实现逻辑应该是什么样的呢? 感觉应该容易实现,走一个通用的配置中心就可以了,SDK来读取配置中心下发的记录用户行为配置策略,或者配置中心去下发这种策略。下发遇到的问题比较多,SDK来主动拉取能好一些。

    2024-07-21

  • 阳阳 👍(0) 💬(1)

    这些前端记录的日志,如果遇到网络层面导致保存后端失败,那么该如何处理呢?如何保证前端的这些用户行为、异常等日志一定会保存到后端的数据库呢?

    2024-05-13