前端埋点数据收集及上报方案
最近与后端同学一起完成了项目中数据埋点系统的重构,期间遇见了不少问题,收获颇多,因此记录下来,主要包括:埋点系统的字段设计、在Vue项目中实现声明式数据收集、数据上报等。
背景
之前项目的埋点处于刀耕火种的原始阶段
- 由开发根据需求在业务代码中手动埋点,如
addLog('page1')
,addLog('page1_btn_click')
等名称,- 到目前大概有一百多个各种各样的埋点type散落在代码中,没有规范的文档来整理这些埋点
- 前端通过
ajax
上报数据,后台使用mongodb
来保存用户日志- 与业务接口使用同一个域名,可能占用浏览器并发请求数量限制
- 页面跳转时可能会将请求abort掉,导致数据上报失败
过去一年经历了项目从起步到业务快速迭代和增长,早期的用户数据埋点已经不能满足我们的业务需求了,尤其是在数据查询阶段,对开发和运营来说无疑是噩梦
- 首先运营提出需要查看哪些数据,不同维度的数据可能分布在业务数据表、日志表等地方
- 前端找到对应日志的名称,然后提供给后端
- 后端编写相关的数据脚本,对于不同的需求可能需要编写不同的查询脚本,十分繁琐
以至于后来前端也开始帮忙写数据脚本了
// pv
db.getCollection('logs').find({type:"xxx", 'condition1':'value1'}}).count()
// uv
db.getCollection('logs').distinct("username", {type:"xxx", 'condition1':'value1'}})
这种状况持续了一段时间,赶上需求比较多的时候,简直就是噩梦(顺道吐槽一下公司都搞了好几年了,这些基础设施的技术沉淀都没有,每个项目组貌似都是隔离的...),是时候来优化了
埋点字段设计
在上面的场景中,是由开发人员在编写埋点代码的时候自定义的,导致后面需要深入到代码中去查询对应的type,
对于不同的type,可能会携带额外特定的参数,诸如template_id
、goods_list
之类的特殊含义字段,在找到对应的埋点代码之前,可能根本无法编写查询语句。
即使将常用的埋点封装成独立查询功能,也无法从根本上满足查询需求。
为了解决这些问题,需要
- 设计一套通用的字段,避免随意扩展数据结构,减少查询成本
- 找一个地方维护type,可以直接查阅对应目标的埋点字段,减少沟通成本
通用字段
根据我们的业务需求,整理了需要上报的通用字段
uuid
,用户id,在用户未登陆的页面需要上报本地唯一的随机uuid区分url
,当前事件对应的urleventTime
, 触发上报的时间戳clientTime
, 用户客户端时间,使用YYYY-MM-DD HH:mm:ss
字符串时间表示,方便统一不同时区用户按时间字符串查询osType
,用户设备系统,包括Android
、iOS
、Mac
和Windows
等clientType
,客户端类型,代表当前上报的应用来源,目前项目分为了PC端、移动端和App端clientVersion
,应用版本号utm
和source
,渠道来源
页面及埋点类型
前端大部分功能是按页面维度实现的,因此埋点也按照页面来统计应该比较合理的,由于url可能会运行平台掺杂额外的query参数,因此单独设计一个pageType
字段,表示对应页面,
然后一个页面上可能会上报多种事件,主要包括
pv
页面访问次数,uv
直接根据用户去重计算,不需要单独上报- 页面停留时间,主要在页面切换或页面卸载时上报
- 曝光,某些特定的业务场景需要统计元素出现在屏幕中的次数等
- 交互事件、如点击、长按等
- 逻辑事件,在某些流程节点后需要上报的逻辑,如登录成功、验证码校验完毕后上报
将上面的事件类型称为为eventType
,每种事件可能有额外的参数,称为eventValue
eventType | eventValue |
---|---|
pv | 无 |
页面停留 | 停留时间,单位ms |
曝光 | 无 |
交互事件 | 触发事件的元素,如按钮名称 |
逻辑事件 | 无 |
考虑到某些埋点需要上报额外的参数,因此还涉及了一个 extra
字段,取值为JSON字符串,对于某个页面事件而言,其extra应该是固定的结构,不应该随意扩展字段。
小结
按照上面的设计,上报参数的数据结构如下
const params = {
uuid: userId || getPvUid(),
url: window.location.href,
pageType,
eventType,
eventValue,
clientTime: moment().format('YYYY-MM-DD HH:mm:ss'), // 上传用户本地时间字符串
eventTime: +new Date(),
osType,
clientType: 'mobile', // 按照项目使用不同的枚举值
clientVersion: process.env.VUE_APP_VERSION || '',
utm,
source,
extra: JSON.stringify(extra)
}
在查询的时候,需要指定pageType
页面类型、eventType
事件类型来查询某个页面单个事件的日志;
反之,我们也可以按页面维度来维护埋点事件:创建一个页面,然后提前创建这个页面会上报的事件
这样,我们按照配置的字段在代码里面埋点,后面查询的时候就不用单独深入代码里面去查询对应的事件了。
那么,如何在代码里面优雅地埋点、而不是埋雷呢?
声明式数据收集方案
目前前端埋点主要有三种方式
- 代码埋点,通过开发人员手动在代码中处理上报逻辑
- 优点,可以在各种业务场景下精确上报任意数据
- 缺点,对业务代码侵入,工作量较大,可维护性较差
- 可视化埋点,通过可视化的页面自定义增加埋点事件,这类工作可以由产品或运营完成
- 优点,解耦埋点代码和业务代码,减少开发人员工作量
- 缺点,无法定制埋点参数等
- 自动埋点,全量上报相关事件的埋点
- 优点,接入简单,无侵入
- 缺点,采集的是全量数据,无法定制埋点,对后端存储和查询也有干扰
而需要上报的事件
- 对于pv,在进入页面时上报
- 对于停留时间,在离开页面或页面unload时上报
- 对于点击等交互事件,在事件触发时上报
- 对于曝光,在DOM进入视窗时上报
可以看见,除了交互事件之外,其余事件貌似都可以自动上报,而无须在业务代码中插入埋点代码。结合业务,经过讨论后,最终决定采用声明式埋点,一方面减少埋点代码对业务代码的侵入,另一方面也减少了前端编写埋点代码的工作量
所谓声明式埋点,指的是通过配置描述页面需要上报哪些埋点,然后在对应的时机上报数据。由于我们的前端项目基于Vue
开发,因此下面主要介绍在Vue中实现声明式埋点的方法。
通过mixin处理pv和停留
对于pv和页面停留,最直观的感觉是在vue-router
的导航钩子如afterEach
中处理,由于单个route对象可以通过meta
属性挂载额外参数,因此可以用来保存埋点的pageType字段
router.afterEach((to, from) => {
// 上报当前页面的访问事件
trackPvLog(to)
// 上报上一个页面的停留事件
trackDurationLog(from)
})
但与自动埋点类似,这种做法存在一些问题
- 某些页面可能不希望上报数据
- 某些页面存在重定向行为,或者需要执行某些异步逻辑之后才能算作真实的访问
- 某些页面需要上报动态的参数,而根据
route
对象没法直接获取到这些参数
基于上面这些问题,最终决定通过mixin
来处理pv和页面停留的上报
- 路由组件提供了
beforeRouteEnter
、beforeRouteLeave
等接口 - 组件可以读取$route.meta等获取路由配置项
- 可以统一封装上报逻辑,由组件自己决定是否上报
- 可以直接从路由组件中读取数据,方便传递动态参数
重新设计一下route的meta属性,约定新增log
配置项,主要包含下面字段,方便在创建路由对象时就声明该页面的埋点逻辑,而基本上无需修改原本的组件代码
const createLogParams = (name, pv = true, duration = false, async = false) => {
return {
name,
pv,
duration,
async
}
}
- name 表示当前页面的名称
pageType
,该页面产生的所有事件都会使用该名称 - pv 表示是否统计页面pv、uv,默认为true
- duration 表示是否统计页面停留时间,默认为false
- async表示是否为异步页面,主要用于某些需要等待查询或检测之后再上报真实pv的场景
- 默认为false,进入页面则上报pv
- 当配置为true时,需要在路由组件内手动调用 通过mixin向对应组件注入的onPageReady方法,通知系统上报pv
created() {
// 一些异步操作
if (isValid) {
// 通知mixin准备就绪
this.onPageReady && this.onPageReady();
} else {
// 不上报pv
this.$router.replace({
path: "/404",
});
}
}
然后向路由组件注入mixin,一种方案是在组件内通过mixins
配置项手动引入,一种是在初始化router对象时添加包装,为了统一管理依赖,在目前的项目中采用的是第二种,为此实现了一个包装方法
export const injectLogMixin = routeComponent => {
const mixin = component => {
if (!Array.isArray(component.mixins)) {
component.mixins = []
}
component.mixins.push(logMixin)
return component
}
// 处理异步组件
if (typeof routeComponent === 'function') {
const origin = routeComponent
return () => {
return origin().then(ans => {
return mixin(ans.default)
})
}
}
return mixin(routeComponent)
}
然后在初始化router的时候,route配置项就变成了下面这种形式
const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'index',
component: injectLogMixin(Home), // 向Home组件注入mixin
meta: {
log: createLogParams('homePage', true, true, false) // 在meta上声明log相关配置
}
},
]
})
通过mixin处理动态埋点参数
在上报接口处封装了基础的公共参数,如osType、utm等等;但在某些页面上,可能需要自定义埋点参数或传入extra参数。
为了避免在这种场景下手动调用埋点接口,因此约定了一些配置项,通过mixin暴露了两个公共字段 _logExtend
和 _logExtra
,由各组件自定义声明额外参数
computed: {
_logExtend() {
return { utm: this.$route.query.from }
},
_logExtra(){
return { id: this.$route.query.id }
}
},
并在上报该组件的所有事件埋点时,均会自动将_logExtend
合并到外层参数,将_logExtra
合并到extra字段中,无须手动修改上报接口。
因此,在实现自动处理pv、停留时间、额外埋点参数等功能之后,整个mixin的大致实现为
import { trackPvLog, trackDurationLog } from '@/util/statistics'
export function getPageName(route) {
if (!route) {
return ''
}
const { log: logConfig } = route.meta
return (logConfig && logConfig.name) || route.name
}
export function getLogParams(context) {
// 预留组件自己定义传入的参数
const { _logExtend = {}, _logExtra = {} } = context
return {
extend: {
..._logExtend
},
extra: {
..._logExtra
}
}
}
export default {
data() {
return {
_pageReady: false
}
},
computed: {
logConfig() {
return this.$route.meta.log
},
_logParams() {
return getLogParams(this)
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.$$enterTime = +new Date()
const { log: logConfig } = vm.$route.meta
if (logConfig && !logConfig.async) {
// 异步埋点需要等待自己打点
vm._trackPvLog()
}
})
},
beforeRouteUpdate(to, from, next) {
this._trackDurationLog()
next()
this._trackPvLog()
},
beforeRouteLeave(to, from, next) {
this._trackDurationLog()
next()
},
// 页面卸载时处理停留埋点
mounted() {
window.addEventListener('unload', this._trackDurationLog)
},
beforeDestroy() {
window.removeEventListener('unload', this._trackDurationLog)
},
methods: {
// pv埋点
_trackPvLog() {
const { logConfig } = this
if (!logConfig || !logConfig.pv) return
const name = getPageName(this.$route)
const { extend, extra } = this._logParams
trackPvLog(name, extend, extra)
},
// 页面停留埋点,计算页面停留时间
_trackDurationLog() {
const { logConfig } = this
if (!logConfig || !logConfig.duration) return
const now = +new Date()
const time = now - this.$$enterTime
const name = getPageName(this.$route)
const { extend, extra } = this._logParams
this.$$enterTime = now
trackDurationLog(name, time, extend, extra)
},
// 等待页面准备完毕后再通知埋点
onPageReady() {
this._pageReady = true
this._trackPvLog()
}
}
}
通过指令处理交互事件
在前面提到,交互事件需要在对应事件触发时上报,同样,为了避免手动调用接口,我们可以将逻辑封装成Vue自定义指令,取名为v-log
,然后通过不同修饰符区分事件类型,如v-log.click
表示点击事件、v-log.longtap
表示长按事件。
其预期使用方法为
<div
class="btn-next"
v-loading="isVerifying"
:class="{ disabled: !canNext }"
@click="confirm"
v-log.click="{ key: 'btn-confirm' }"
>下一步</div>
该指令携带的binding参数包括
- key 点击的按钮名称,对作为eventValue参数上报
- extend, 额外的extend参数,非必填
- extra,额外的extra参数,非必填
其大致实现为
function getBindingLogParams(binding, store, context) {
// 额外支持从binding传入extend和extra参数
const { value = {} } = binding
const { extend, extra } = getLogParams(store, context)
return {
extend: {
...extend,
...(value.extend || {})
},
extra: {
...extra,
...(value.extra || {})
}
}
}
export const log = {
bind(el, binding, vNode) {
// todo 预留数据校验的钩子
const { modifiers } = binding
const { click } = modifiers
const { context } = vNode
if (click) {
const { $route } = context // 获取当前路由
el.addEventListener('click', () => {
const name = getPageName($route)
const { extend, extra } = getBindingLogParams(binding, context)
const { value } = binding
const { key } = value
trackClickLog(name, key, extend, extra)
})
}
// todo 其他交互事件
}
}
这里使用了与mixin一样的
getPageName
方法,通过$route获取当前页面配置的pageTypegetLogParams
方法,因此都支持从当前组件获取额外的动态参数_logExtend
和_logExtra
。
因此,通过自定义指令,无需再为交互事件编写埋点代码。
小结
在整个数据收集方案中,通过声明尽可能地避免手动埋点,且在一定程度上保证埋点的灵活性,这样可以保证开发同学无须过度关心埋点代码,也能够正确上报数据
- 在route配置的meta中声明埋点的基本配置项
- 通过自定义指令上报交互事件
但不可避免的是,还是会存在在部分依赖手动上报的场景,如接口调用成功后的埋点等,需要提前建好维护好对应事件,然后手动埋点。在实际开发场景中,应尽量避免这种做法。
解决了数据收集的问题之后,我们来看一看数据上报的问题。
数据上报方案
在文章开头场景中提到,我们需要解决的数据上报问题包括
- 应该尽量保证在页面跳转或关闭前的埋点正常上报,而不是创建同步ajax或者setTimeout延迟页面跳转
- 数据上报是整个页面优先级比较低的请求,不应该跟业务接口竞争请求资源
接下来讨论当前比较流行的前端数据上报方案。
Beacon
为了解决第一个问题,我们可以使用新的API:Beacon - MDN文档
Beacon用于将数据异步发送到服务器,并由浏览器保证在页面完成卸载之前发送请求,这样就无需开发者来实现相关的逻辑
navigator.sendBeacon(url, data);
使用Beacon需要后台使用POST
请求接收参数,请请求header必须是CORS-safelisted request-header,要求content-type
必须是下面三种值之一,为了避免CORS,因此可能需要后台改造相关接口
text-plain
默认值application/x-www-form-urlencoded
multipart/form-data
具体的content-type
跟data的数据类型有段,可以通过Blob快速指定
function sendBeacon(url, params) {
const data = new URLSearchParams(params)
const headers = {
type: 'application/x-www-form-urlencoded'
}
const blob = new Blob([data], headers)
navigator.sendBeacon(url, blob)
}
Beacon存在一些兼容性问题,可以在can i use上查看。为了向后兼容,我们还可以使用像素打点。
像素埋点
所谓像素打点,就是构建一个1像素的Image请求上报数据埋点,由于大部分浏览器会在页面卸载前完成图片的加载,因此也可以上报取消的情况。
function sendPxPoint(url, params) {
const img = new Image()
img.style.display = 'none'
const removeImage = function() {
img.parentNode.removeChild(img)
}
img.onload = removeImage
img.onerror = removeImage
const data = new URLSearchParams(params)
img.src = `${url}?${data}`
document.body.appendChild(img)
}
可以看见,像素埋点是通过设置Image对象的src属性,然后将其插入到文档中实现的,因此发送的是GET
请求,链接上携带的参数也不能过长,避免出现HTTP 414
错误。
小结
在新的方案中,优先使用Beacon上报数据,向后兼容使用像素埋点
const url = 'https://www.shymeaan.com/log/report'
function sendLog(params) {
if (navigator.sendBeacon) {
sendBeacon(url, params)
} else {
sendPxPoint(url, params)
}
}
同时可能还需要使用url-search-params-polyfill。
总结
本文整理了在本次埋点系统的重构中关于字段设计、数据收集和数据上报,
- 按页面维度设计事件
- 实现了一种在Vue项目中声明式收集数据的方案,这个方案理论上也是可以运用在React等单页项目中
- 使用Beancon上报数据,向后兼容使用像素埋点
目前整个系统已经在试运行中,每天大概有几十万条数据,看起来还比较稳定,后续还可以补充日志存储方案和数据可视化展示,等有空了再完善一下。
存在的问题
在项目运行了一段时间之后,发现通过logExtend
和logExtra
配置项声明公共参数也存在一些问题:
- 当需要上报的事件分散在组件树的各个角落时,都需要声明公共参数,这也是十分繁琐和重复的工作
- 此外声明式舍弃了原本的代码逻辑,不容易追踪和排查问题
- 此外还有部分弹窗组件,是挂载在全局的body节点下面,并没有在当前路由组件树中,不一定能拿到对应的context获取公共参数
针对上面问题,经过实践目前的一些处理方案
对于跨层的组件公共参数
- 状态提升到路由组件
- 使用全局状态维护
对于脱离组件树的公共参数,
- 通过props传入
- 使用全局状态维护,如放在vuex中
感觉统一逻辑,放在store里面应该是最简单的,即通过vuex来触发上报行为,不过这感觉又回到了代码侵入式的埋点,还需要继续观察一段时间,再回头来总结具体方案。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。