前端监控SDK:从基础到实践 (3. 行为监控)

前言

上篇文章我们分享了错误监控相关的内容,这次我们来讲讲行为监控的内容 在文章中,我会结合 monitor-sdk 的实现细节,和给大家学习搭建的demo来逐步拆解前端监控的各个模块,带领大家从 0 到 1 搭建属于自己的监控平台。

用户行为分析

我把sdk收集上报的行为分为两类,一类是错误录屏,另一类是用户在页面的各种操作

错误录屏回放

当用户在页面进行操作时可能会发生一些错误,那么我们作为开发者,我们需要知道用户是进行了什么操作进而才导致出了错误,所以我们需要对页面进行一个录屏,还原出用户的错误操作,进而进行修复bug。

录屏方法调研 经过调研得出录屏的两种办法:WebRTC和rrweb

特性 WebRTC rrweb
存储大小 视频文件较大,占用存储较多 JSON 数据轻量,占用存储较少
性能消耗 性能消耗低,录制对前端性能影响小 高频事件可能导致性能下降
数据分析能力 无法直接分析用户操作细节,只能通过回放观察 数据格式化后便于分析用户行为
录制范围 全屏录制,覆盖所有内容,包括浏览器外部弹窗 仅限 Web 页面内容,不支持浏览器外的操作
实时性 支持实时传输,适合直播等场景 数据收集后需上传并解析,无法实时传输
隐私保护 需要用户授权,难以在未授权的场景下使用 可过滤敏感数据,灵活性更强
适合场景 适用于需要高保真视觉效果的场景,如全屏录制、用户流分析 适用于开发调试、还原用户交互细节
成本 内置浏览器支持,免费 开源免费

在实际场景中,webRTC需要征求用户的意愿才能开启,如果用户不同意那么就没办法录制,故选用rrweb

rrweb原理

在展示代码前我先讲解讲解rrweb的原理。

  1. 生成快照:在页面开始时rrweb会先生成一份页面的快照,用于记录网页初始状态

    1. 快照的原理:使用深度优先遍历对 DOM 树进行序列化,记录每个节点的属性(如 tagNameidclassName)、子节点、样式、位置信息等。
    2. 生成的快照数据结构是一个 JSON 格式对象,包含页面的完整初始状态。
  2. 捕获用户的操作:监听用户在页面上的所有操作事件,包括鼠标事件、键盘事件、滚动事件、窗口大小改变、视图变化等。这些事件被序列化成一系列"增量快照"。

    事件序列化格式:

    时间戳:事件发生的时间。

    事件类型 :例如 mousemoveclick 等。

    位置或内容变化:例如鼠标的位置、输入框的新值。

    目标元素:事件发生的目标元素的引用。

  3. 增量快照

    1. 增量快照的作用:记录用户操作导致的页面变化,例如,用户点击一个按钮后,按钮的 className 发生变化,这种局部变化会被记录为增量快照
    2. 增量快照的实现:使用 MutationObserver 监听 DOM 的变化,记录新增、删除、修改的节点和属性
  4. 事件轴管理

    rrweb 会为每个事件分配一个时间戳,用于记录事件的发生时间。时间戳有助于在回放时正确地还原事件的顺序和间隔

  5. 回放

    1. 根据初始快照重建 DOM 树,初始化回放页面。
    2. 依次读取事件流,根据时间戳和事件类型触发相应的操作
    3. rrweb 会根据事件流的时间戳计算事件之间的间隔,确保回放时的操作流畅且符合真实用户操作

现在原理讲完了再来讲讲如何把它集成到sdk中

rrweb使用

先安装需要的依赖

shell 复制代码
npm install rrweb
npm install pako
npm install js-base64

需要用到的数据结构

typescript 复制代码
export type RecordEventScope = {
  scope: string
  eventList: any[]
}

核心逻辑

js 复制代码
export class RecordScreen {
  public eventList: RecordEventScope[] = [
    { scope: `${Date.now()}-`, eventList: [] }
  ]
  public scopeScreenTime = 3000
  public screenCnt = 3
  private closeCallback: ReturnType<typeof record>

  constructor() {
    this.init()
  }

  init = () => {
    this.closeCallback = record({
      emit: (event, isCheckout) => {
        const lastEvents = this.eventList[this.eventList.length - 1]
        lastEvents.eventList.push(event)
        if (isCheckout) {
          if (this.eventList.length > 0) {
            this.eventList[this.eventList.length - 1].scope =
              lastEvents.scope + Date.now()
          }
          if (this.eventList.length >= this.screenCnt) {
            this.eventList.shift()
          }
          this.eventList.push({ scope: `${Date.now()}-`, eventList: [] })
        }
      },
      recordCanvas: true,
      checkoutEveryNms: this.scopeScreenTime // 每5s重新制作快照
    })
  }

  close() {
    this.closeCallback?.()
    this.closeCallback = undefined
  }
}

let recordScreenInstance: RecordScreen

export const getRecordScreen = () => {
  return recordScreenInstance
}

export const getRecordScreenData = () => {
  const recordScreen = getRecordScreen()
  const eventList = recordScreen?.eventList.slice(-2) || []
  const data = eventList.reduce(
    (pre, cur) => {
      return [...pre, ...cur.eventList]
    },
    [] as RecordEventScope['eventList']
  )
  const eventData = zip(data.flat())

  return eventData
}

事件回调逻辑

  • emit(event, isCheckout) 是事件的回调:

    • event:新产生的事件数据。
    • isCheckout:是否触发快照分段。
  • 操作步骤:

    1. 将新事件追加到当前事件段的 eventList 中。

    2. 如果 isCheckouttrue

      • 更新当前段的 scope,在其原有值后添加时间戳。
      • 如果事件段的数量超过 screenCnt,移除最早的一段。
      • 创建一个新的事件段,scope 为新的时间戳,eventList 为空。

为什么要做一个数组去存储录像数据呢?

数组长度是3,每隔3s保存一个数组单位。假设此时在第10s的时候出现错误需要上报录屏数据。此时的数组数据就是 ,然后我们取出数组的后两位,就是6-9,和9-10s的数据,将他们合并,然后就是上报6-10s的数据了。这样可以大大减少上报的数据大小。

其实上报录屏的数据它是非常大的,所以我们非常有必要进行数据的压缩再上报。获取数据的时候先解压再传给rrweb

js 复制代码
import pako from 'pako'
import { Base64 } from 'js-base64'

/**
 * 压缩
 * @param data 压缩源
 */
export function zip(data: any): string {
  if (!data) {
    return data
  }

  // 判断数据是否需要转为JSON
  const dataJson =
    typeof data !== 'string' && typeof data !== 'number'
      ? JSON.stringify(data)
      : data

  // 使用Base64.encode处理字符编码,兼容中文
  const str = Base64.encode(dataJson as string)
  const binaryString = pako.gzip(str)
  const arr = Array.from(binaryString)
  let s = ''
  arr.forEach((item: number) => {
    s += String.fromCharCode(item)
  })
  return Base64.btoa(s)
}

/**
 * 解压
 * @param b64Data 解压源
 */
export function unzip(b64Data: string) {
  const strData = Base64.atob(b64Data)
  const charData = strData.split('').map(function (x) {
    return x.charCodeAt(0)
  })
  const binData = new Uint8Array(charData)
  const data: any = 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: any) {
    if (/Unexpected token o in JSON at position 0/.test(error)) {
      // 如果没有转换成功,代表值为基本数据,直接赋值
      result = unzipStr
    }
  }
  return result
}

然后在拿到录屏数据就可以进行一个回放,详细逻辑可以看demo代码src/components/ReplayComponent.tsx

随便触发一个错误然后播放错误录屏

用户行为收集

用户在页面中可能有如下行为:

  • 鼠标行为
  • 键盘行为
  • 页面跳转行为
  • 点击行为

通过收集这些用户行为数据,我们可以分析用户的行为模式、偏好和需求,从而优化网站或应用的设计和功能,提高用户体验和业务指标。亦或是页面发生错误时,我们可以根据这些行为来还原错误。

这部分内容我大部分都是取自juejin.cn/post/710084... 详细内容可自行去看文章,或者看sdk源码我这就只贴一部分了

js 复制代码
export class Behavior {
  // 本地暂存数据在 Map 里 (也可以自己用对象来存储)
  public metrics: any

  public breadcrumbs: any

  public customHandler!: Function

  // 最大行为追踪记录数
  public maxBehaviorRecords!: number

  // 允许捕获click事件的DOM标签 eg:button div img canvas
  public clickMountList!: Array<string>
  static instance: any

  constructor() {
    if (Behavior.instance) {
      return Behavior.instance
    }
    this.maxBehaviorRecords = 25
    // 初始化行为追踪记录
    this.breadcrumbs = new BehaviorStore({
      maxBehaviorRecords: this.maxBehaviorRecords
    })
    // 初始化 用户自定义 事件捕获
    this.customHandler = this.initCustomerHandler()
    this.clickMountList = ['click'].map(x => x.toLowerCase())
    // 重写事件
    wrHistory()
    // 初始化路由跳转获取
    this.initRouteChange()
    // 初始化 PV 的获取;
    this.initPV()
    // 初始化 click 事件捕获
    this.initClickHandler(this.clickMountList)
    window.$SDK.Behaviour = this
    Behavior.instance = this
  }

  // 初始化用户自定义埋点数据的获取上报
  initCustomerHandler = (): Function => {
    const handler = (reportData: customAnalyticsData) => {
      // 自定义埋点的信息一般立即上报
      const data = {
        ...reportData,
        type: TraceTypeEnum.behavior,
        subType: TraceSubTypeEnum.tracker,
        timestamp: new Date().getTime()
      }
      lazyReportBatch(data)
    }
    return handler
  }

  // 初始化 RCR 路由跳转的获取以及返回
  initRouteChange = (): void => {
    let oldDate = Date.now()
    const handler = (e: Event) => {
      // 记录到行为记录追踪
      const behavior: RouterChangeType = {
        type: TraceTypeEnum.behavior,
        subType: TraceSubTypeEnum.routerChange,
        pageUrl: window.location.href,
        jumpType: e.type, // 跳转的方法 eg:replaceState
        timestamp: new Date().getTime(),
        pageTime: Date.now() - oldDate
      }
      oldDate = Date.now()
      this.breadcrumbs.push(behavior)
    }
    proxyHash(handler)
    // 为 pushState 以及 replaceState 方法添加 Evetn 事件
    proxyHistory(handler)
  }

  // 初始化 PV 的获取以及返回
  initPV = () => {
    const handler = () => {
      const reportData: PvInfoType = {
        type: TraceTypeEnum.behavior,
        subType: TraceSubTypeEnum.pv,
        timestamp: new Date().getTime(),
        // 页面信息
        pageInfo: getPageInfo(),
        // 用户来路
        originInfo: getOriginInfo()
      }
      // 一般来说, PV 可以立即上报
      lazyReportBatch(reportData)
    }
    afterLoad(() => {
      handler()
    })
    proxyHash(handler)
    // 为 pushState 以及 replaceState 方法添加 Evetn 事件
    proxyHistory(handler)
  }

  // 初始化 CBR 点击事件的获取和返回
  initClickHandler = (mountList: Array<string>): void => {
    const handler = (e: MouseEvent | any) => {
      const target = e.target as HTMLElement
      if (!target) {
        return
      }
      const behavior: TargetInfoType = {
        type: TraceTypeEnum.behavior,
        subType: e.type as string,
        tagName: target.tagName,
        pageUrl: window.location.href,
        path: getPathToElement(target),
        timestamp: new Date().getTime(),
        textContent: target.textContent
      }
      lazyReportBatch(behavior)
      this.breadcrumbs.push(behavior)
    }
    mountList.forEach(eventType => {
      window.addEventListener(
        eventType,
        e => {
          handler(e)
        },
        true
      )
    })
  }
}

尾言

参考文档 juejin.cn/post/710084...

以上就是行为监控的全部内容了,笔者不一定正确,如果大家有发现有错误或有优化的地方也可以在评论区指出。下一篇会写页面监控异常的内容。

最后如果文章对你有帮助也希望能给我点个赞,给我的开源项目点个star,monitor-sdk 你的支持是我学习的最大动力。

相关推荐
不怕麻烦的鹿丸11 分钟前
web前端录制canvas视频和video的声音,并合并成一个文件进行下载
前端·javascript·音视频·canvas
我不当帕鲁谁当帕鲁43 分钟前
arcgis for js实现平移立体效果
前端·javascript·arcgis
录大大i1 小时前
HTML之CSS定位、浮动、盒子模型
前端·css·html
P7进阶路1 小时前
Ajax:重塑Web交互体验的人性化探索
前端·javascript·ajax
bin91532 小时前
DeepSeek 助力 Vue 开发:打造丝滑的步骤条
前端·javascript·vue.js
zengyuhan5032 小时前
当Rust邂逅DLL:Tauri桌面开发的硬核调用指南
前端·rust·libra
高台树色3 小时前
情人节快到了,一起来看烟花吧🎆🎆
javascript
ZeZeZe3 小时前
数据结构之栈与队列
前端·javascript
求索773 小时前
CSS 画水滴效果 - 意想不到的简单!
前端·css
炉火旁打滚3 小时前
无限循环滚动不定宽横幅
前端·javascript