埋点管理
简单实现一个埋点sdk和管理平台
What
埋点是对用户在客户端产品发生的事件的描述
- Who:用户
- When:timestamp
- What:用户会有哪些行为??比如 打开页面、点击按钮、滚动列表、登录、分享等等
- How:比如 渠道(从哪里访问页面的)、登录方式(email/mobile/sso/…)
Why
埋点可以帮助业务分析用户行为,从而更好的理解用户习惯、优化产品
How
EventType
一般可以划分为2个大类
- 页面打开
PageShow
,需要单独统计,因为页面的pv/uv
是埋点中非常重要的一个数据指标 - 用户主动交互
Behavior
click
点击行为exposure
模块的曝光
enum IEventType = {
PAGE_SHOW = 'pageShow',
CLICK = 'behavior.click',
EXPOSURE = 'behavior.exposure',
}
数据规范
pageShow
对于页面打开 PageShow
,就很简单,一般统计 当前页面 url/pageId
即可
behavior
试想怎么描述 『点击了登录按钮』这件事件,至少需要
interface IEvent {
eventId: string, // 行为 `id`,比如下载download, 分享 share, 登录 login
eventType: IEventType,
eventTarget: IEventTarget[], // 事件对象的描述,类似 `js` 里的 `event.target`
timestamp: number, // 发生时间
}
EventTarget
事件对象的相关信息,比如
- 点击登录,统计 登录方式(验证码登录、密码登录)
- 添加购物车,统计 商品Id、商家 id
- 分享商品,统计 分享的商品id,分享渠道
举个例子,分享商品
{
eventId: 'share',
eventType: IEventType.CLICK,
eventTarget: [
{
goodId: '1',
channel: 'wechat',
},
{
goodId: '2',
channel: 'wechat',
}
],
timestamp: Date.now()
}
上面这个 eventTarget
结构上有一点点问题,不方便后续的数据分析,上面的例子中事件对象是 goodId
,换到其他业务,这个对象可能是 projectId
{
eventId: 'share',
eventType: IEventType.CLICK,
eventTarget: [
{
projectId: '1',
},
{
projectId: '2',
},
],
timestamp: Date.now()
}
可以发现,eventTarget
结构不固定,无法统一分析。优化后的结构如下,其中 extend
作为扩展字段,存储其他业务信息,比如说上面的渠道
interface IEventTarget {
targetKey: string // 这个就是前文中的 goodId/projectId
targetValue: string
extend?: { [k: string]: any }
}
优化后如下
{
eventId: 'share',
eventType: IEventType.CLICK,
eventTarget: [
{
targetKey: 'goodId',
targetValue: '1'
extend: {
channel: 'wechat',
}
},
{
targetKey: 'goodId',
targetValue: '2'
extend: {
channel: 'wechat',
}
}
],
timestamp: Date.now()
}
上下文环境
除了事件本身,还需要知道事件发生时所处的环境,
- 设备信息
- 业务应用信息
- 用户信息
- 页面信息
综上,我们可以定义一条完备的日志数据结构
interface ITrack {
common: {
appId: string // 应用ID
appName: string, // 应用名称
appVersion: string // 应用版本
os: string, // 系统
ua: string, // 浏览器
sr: string, // 屏幕分辨率
},
page: {
url: string,
// pageId: string, // 只要能区分page即可
},
user: {
userId?: string
},
event?: IEvent,
timestamp: Number, // 发送日志的时间
}
埋点平台管理
除了 SDK
之外,还需要一个埋点平台管理所有埋点,因为开发需要和 BI
统一数据口径,而平台就是用来约束埋点值的。具体来说,开发埋点之前,需要去 平台申请对应点位,然后写在业务里。
实际工作中,埋点平台并没有强约束开发必须要申请,但是如果开发随便写点位,没有去平台申请,后续和 BI 对字段的时候,需要去看代码才知道埋的是什么值,这就很浪费时间。为了不给自己麻烦,尽量还是先申请点位,而且也是为了方便后续的埋点验证工作。
埋点验证
最后,为了确定点位确实埋好了,我们需要验证。一般是看 network
的请求参数,不过这个方式有几个缺点
- 业务请求太多,难以清晰的找到埋点的接口,需要开启过滤
- 一般而言,
h5
非模拟器环境是无法直接看到请求的
针对以上问题,可以考虑2种小工具提高验证效率
SDK
内置UI
查看工具,自动显示每次的埋点数据- 打通埋点平台,拦截业务发起的埋点请求,对其进行数据结构的校验(无法验证埋点是否合理)
埋点方式
埋点有可视化埋点、自动埋点、手动埋点,各有利弊
- 可视化埋点
- 自动埋点
- 手动埋点
我们业务比较简单,就采用了手动埋点
发送时机
上面我们已经确定了埋点的数据结构,接下来要考虑什么时候发出去,一般有2种方式
-
实时发送,简单,事件发生就发生一条日志
- 对接口密集型业务,会影响业务的请求
- 几乎不存在数据丢失的情况
-
延时发送,比较复杂,需要考虑很多东西,比如
- 发送时机,比如页面切换 或者 定时器机制;
- 未来得及发送的数据进行缓存,下次进入页面再发送;
- 比较容易存在数据丢失的情况
怎么发送
发送埋点本质就是发送一条请求,可能大家就说那不简单,xhr
不就得了,但是,有几个问题需要考虑
- 发送时机,要知道如果在页面离开发送请求,一般浏览器会中断该请求,除非某些浏览通过代码配置允许不中断请求
xhr
会有跨域问题
XHR
img
sendBeacon
SDK
初始化
根据前面的数据结构定义,需要外部传入的参数只有应用元信息、用户身份
type IUser = {
userId: string
}
type IOption = {
appId: string,
appName: string,
appVersion: string
userInfo: () => IUser
}
在SDK初始化的时候,会做几件事情
-
初始化上下文信息
-
页面信息管理
-
注册插件系统,在埋点最后上报之前,提供给业务进行数据处理的钩子
class MyTracker {
private common
private pageStack: MyPage
private _transform
constructor(opts: IOption) {
const { appId, appName, appVersion } = options || {}
this.common = {
os: getOS(), // 系统
ua: getBrowser(), // 浏览器
sr: getScreen(), // 屏幕分辨率
appId,
appName,
appVersion,
}
this.pageStack = new MyPage()
// 插件注册
this._transform = compose(options?.plugins || [])
}
}
MyPage
负责页面信息管理,默认只保留当前访问的页面信息
class MyPage {
private history: { url: string }[]
private maxLength
constructor(opts?) {
this.maxLength = opts?.maxLength || 1
}
record() {
this.history.push({ url: location.href })
this.history.slice(-1 * this.maxLength)
}
last() {
return this.history[this.history.length - 1]
}
}
最后,暴露2个上报方法给业务
// 上报pageShow
page(page?: IPage, params = null) {
this.pageStack.record(page)
this._report({
type: IEvent_TYPE.PAGE_SHOW,
})
}
// 上报behavior
track(log: IEvent) {
this._report({
type: IEvent_TYPE.CLICK,
log,
})
}
插件系统
埋点发送之前,会经过一系列注册的插件,类似 axios
的 interceptors
,一个顺序执行的插件流
export function compose(plugins) {
if (!Array.isArray(plugins)) {
throw new Error('plugins must be an array')
}
if (plugins?.some(p => !isFunction(p))) {
throw new Error('plugins must be all functions')
}
return (ctx, initialData, doneCallback) => {
let lastIndex = -1
const step = async (index: number, data: any) => {
if (index == lastIndex) {
throw new Error('next() called multiple times')
}
if (index == plugins.length) {
return doneCallback(data)
}
lastIndex = index
const callback = plugins[index]
await callback(ctx, data, (nextData: any) => {
step(index + 1, nextData)
})
}
step(0, initialData)
}
}
前面说明,埋点验证有2个小工具,一个是UI查看工具,一个是自动验证,本质就是2个插件
UI查看工具
UI查看工具的话,我们是封装一个 web component
,业务开启的话,会在业务页面弹出一个浮层,每次埋点上报都能看到埋点数据
class TrackDebug extend HTMLElement {
container
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
shadowRoot.appendChild(this.template())
this.container = shadowRoot.querySelector('#track-list')
}
private template() {
const template = document.createElement('template')
template.innerHTML = `<ul id="track-list"></ul>`
return template.content.cloneNode(true)
}
static get observedAttributes() {
return ['data-list']
}
attributeChangedCallback() {
this.render()
}
addLog(data: {
log: string
timestamp: string
}) {
const list = this.list || []
list.unshift(data)
this.setAttribute('data-list', JSON.stringify(list))
}
render() {
let data = []
try {
data = JSON.parse(this.getAttribute('data-list'))
} catch (e) {}
const liStr = data.map((item: { log: string; timestamp: string; }) => {
return `
<li>
<p>${item.log}</p>
<span>${formatTime(item.timestamp)}</span>
</li>`
}).join('')
this.container.innerHTML = liStr
}
}
customElements.define('track-debug', TrackDebug)
然后注册一个插件,每次埋点发送都会经过这里,从而添加到 UI
上即可
let trackIns
export default async (ctx, data, next) => {
if (!trackIns) {
trackIns = document.createElement('track-debug')
document.body.appendChild(trackIns)
}
trackIns?.addLog(data)
next()
}
自动验证
同理,自动验证的话,只需要连接到埋点平台的验证接口,不合规的埋点不会发送请求
export default async (ctx, data, next) => {
// 数据校验
let valid = true
if (ctx.options.validate) {
try {
const res = ctx.options.validate ? await checkValid(data) : await Promise.resolve({ data: true })
valid = !!res?.data
} catch (e) {}
}
// 校验成功,才执行下一个插件
if (!ctx.options.validate || valid) {
next(data)
}
}
最后,插件流转完成,会回到最后的发送接口
private _report(data: { type: IEvent_Type, log?: IEvent }) {
const logData: ITrack = {
common: this.common,
user: this.options?.userInfo()
page: this.pageStack.last,
timestamp: Date.now(),
event: log,
}
// 插件处理
this._transform(this, logData, (finalData) => {
sendImmediateLog(finalData)
})
}
SPM
一些公司采用的埋点方案是 spm
,数据形式类似 siteA.pageB.blockC.targetD
,有4个点位信息
-
a 是应用 id
-
b 是页面 id
-
c 是区块 id
-
d 是事件 id
这些 id
都是在平台申请的,申请下来的 id
也是诸如数字的形式,比如,我们有应用 saas
页面是详情页,发生了一次点击分享行为,这个点位是 a101.b20.c32.d12
。
这个方案相比较前面的方案还是比较复杂的
-
埋点值不是很直观,点位都是数字,很难理解
-
SDK
需要支持自动埋点,不然开发面对这些数字要头大 -
需要完善的埋点平台的支持(怎么申请点位、点位的管理、点位的校验、数据分析等)
这套方案之所以这么复杂,应该是为了满足淘宝的需求,店铺、商品详情页,需要统计各个模块、展位的曝光率、点击率等。