前言
上篇文章我们分享了错误监控相关的内容,这次我们来讲讲行为监控的内容 在文章中,我会结合 monitor-sdk 的实现细节,和给大家学习搭建的demo来逐步拆解前端监控的各个模块,带领大家从 0 到 1 搭建属于自己的监控平台。
用户行为分析
我把sdk收集上报的行为分为两类,一类是错误录屏,另一类是用户在页面的各种操作
错误录屏回放
当用户在页面进行操作时可能会发生一些错误,那么我们作为开发者,我们需要知道用户是进行了什么操作进而才导致出了错误,所以我们需要对页面进行一个录屏,还原出用户的错误操作,进而进行修复bug。
录屏方法调研 经过调研得出录屏的两种办法:WebRTC和rrweb
特性 | WebRTC | rrweb |
---|---|---|
存储大小 | 视频文件较大,占用存储较多 | JSON 数据轻量,占用存储较少 |
性能消耗 | 性能消耗低,录制对前端性能影响小 | 高频事件可能导致性能下降 |
数据分析能力 | 无法直接分析用户操作细节,只能通过回放观察 | 数据格式化后便于分析用户行为 |
录制范围 | 全屏录制,覆盖所有内容,包括浏览器外部弹窗 | 仅限 Web 页面内容,不支持浏览器外的操作 |
实时性 | 支持实时传输,适合直播等场景 | 数据收集后需上传并解析,无法实时传输 |
隐私保护 | 需要用户授权,难以在未授权的场景下使用 | 可过滤敏感数据,灵活性更强 |
适合场景 | 适用于需要高保真视觉效果的场景,如全屏录制、用户流分析 | 适用于开发调试、还原用户交互细节 |
成本 | 内置浏览器支持,免费 | 开源免费 |
在实际场景中,webRTC需要征求用户的意愿才能开启,如果用户不同意那么就没办法录制,故选用rrweb
rrweb原理
在展示代码前我先讲解讲解rrweb的原理。
-
生成快照:在页面开始时rrweb会先生成一份页面的快照,用于记录网页初始状态
- 快照的原理:使用深度优先遍历对 DOM 树进行序列化,记录每个节点的属性(如
tagName
、id
、className
)、子节点、样式、位置信息等。 - 生成的快照数据结构是一个 JSON 格式对象,包含页面的完整初始状态。
- 快照的原理:使用深度优先遍历对 DOM 树进行序列化,记录每个节点的属性(如
-
捕获用户的操作:监听用户在页面上的所有操作事件,包括鼠标事件、键盘事件、滚动事件、窗口大小改变、视图变化等。这些事件被序列化成一系列"增量快照"。
事件序列化格式:
时间戳:事件发生的时间。
事件类型 :例如
mousemove
、click
等。位置或内容变化:例如鼠标的位置、输入框的新值。
目标元素:事件发生的目标元素的引用。
-
增量快照
- 增量快照的作用:记录用户操作导致的页面变化,例如,用户点击一个按钮后,按钮的
className
发生变化,这种局部变化会被记录为增量快照 - 增量快照的实现:使用
MutationObserver
监听 DOM 的变化,记录新增、删除、修改的节点和属性
- 增量快照的作用:记录用户操作导致的页面变化,例如,用户点击一个按钮后,按钮的
-
事件轴管理
rrweb
会为每个事件分配一个时间戳,用于记录事件的发生时间。时间戳有助于在回放时正确地还原事件的顺序和间隔 -
回放
- 根据初始快照重建 DOM 树,初始化回放页面。
- 依次读取事件流,根据时间戳和事件类型触发相应的操作
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
:是否触发快照分段。
-
操作步骤:
-
将新事件追加到当前事件段的
eventList
中。 -
如果
isCheckout
为true
:- 更新当前段的
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
)
})
}
}
尾言
以上就是行为监控的全部内容了,笔者不一定正确,如果大家有发现有错误或有优化的地方也可以在评论区指出。下一篇会写页面监控异常的内容。
最后如果文章对你有帮助也希望能给我点个赞,给我的开源项目点个star,monitor-sdk 你的支持是我学习的最大动力。