前端埋点数据收集及上报方案

最近与后端同学一起完成了项目中数据埋点系统的重构,期间遇见了不少问题,收获颇多,因此记录下来,主要包括:埋点系统的字段设计、在Vue项目中实现声明式数据收集、数据上报等。

<!--more-->

1. 背景

之前项目的埋点处于刀耕火种的原始阶段

  • 由开发根据需求在业务代码中手动埋点,如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'}})

这种状况持续了一段时间,赶上需求比较多的时候,简直就是噩梦(顺道吐槽一下公司都搞了好几年了,这些基础设施的技术沉淀都没有,每个项目组貌似都是隔离的...),是时候来优化了

2. 埋点字段设计

在上面的场景中,是由开发人员在编写埋点代码的时候自定义的,导致后面需要深入到代码中去查询对应的type,

对于不同的type,可能会携带额外特定的参数,诸如template_idgoods_list之类的特殊含义字段,在找到对应的埋点代码之前,可能根本无法编写查询语句。

即使将常用的埋点封装成独立查询功能,也无法从根本上满足查询需求。

为了解决这些问题,需要

  • 设计一套通用的字段,避免随意扩展数据结构,减少查询成本
  • 找一个地方维护type,可以直接查阅对应目标的埋点字段,减少沟通成本

2.1. 通用字段

根据我们的业务需求,整理了需要上报的通用字段

  • uuid,用户id,在用户未登陆的页面需要上报本地唯一的随机uuid区分
  • url,当前事件对应的url
  • eventTime, 触发上报的时间戳
  • clientTime, 用户客户端时间,使用YYYY-MM-DD HH:mm:ss字符串时间表示,方便统一不同时区用户按时间字符串查询
  • osType,用户设备系统,包括AndroidiOSMacWindows
  • clientType,客户端类型,代表当前上报的应用来源,目前项目分为了PC端、移动端和App端
  • clientVersion,应用版本号
  • utmsource,渠道来源

2.2. 页面及埋点类型

前端大部分功能是按页面维度实现的,因此埋点也按照页面来统计应该比较合理的,由于url可能会运行平台掺杂额外的query参数,因此单独设计一个pageType字段,表示对应页面,

然后一个页面上可能会上报多种事件,主要包括

  • pv页面访问次数,uv直接根据用户去重计算,不需要单独上报
  • 页面停留时间,主要在页面切换或页面卸载时上报
  • 曝光,某些特定的业务场景需要统计元素出现在屏幕中的次数等
  • 交互事件、如点击、长按等
  • 逻辑事件,在某些流程节点后需要上报的逻辑,如登录成功、验证码校验完毕后上报

将上面的事件类型称为为eventType,每种事件可能有额外的参数,称为eventValue

eventType eventValue
pv
页面停留 停留时间,单位ms
曝光
交互事件 触发事件的元素,如按钮名称
逻辑事件

考虑到某些埋点需要上报额外的参数,因此还涉及了一个 extra字段,取值为JSON字符串,对于某个页面事件而言,其extra应该是固定的结构,不应该随意扩展字段。

2.3. 小结

按照上面的设计,上报参数的数据结构如下

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事件类型来查询某个页面单个事件的日志;

反之,我们也可以按页面维度来维护埋点事件:创建一个页面,然后提前创建这个页面会上报的事件

这样,我们按照配置的字段在代码里面埋点,后面查询的时候就不用单独深入代码里面去查询对应的事件了。

那么,如何在代码里面优雅地埋点、而不是埋雷呢?

3. 声明式数据收集方案

目前前端埋点主要有三种方式

  • 代码埋点,通过开发人员手动在代码中处理上报逻辑
    • 优点,可以在各种业务场景下精确上报任意数据
    • 缺点,对业务代码侵入,工作量较大,可维护性较差
  • 可视化埋点,通过可视化的页面自定义增加埋点事件,这类工作可以由产品或运营完成
    • 优点,解耦埋点代码和业务代码,减少开发人员工作量
    • 缺点,无法定制埋点参数等
  • 自动埋点,全量上报相关事件的埋点
    • 优点,接入简单,无侵入
    • 缺点,采集的是全量数据,无法定制埋点,对后端存储和查询也有干扰

而需要上报的事件

  • 对于pv,在进入页面时上报
  • 对于停留时间,在离开页面或页面unload时上报
  • 对于点击等交互事件,在事件触发时上报
  • 对于曝光,在DOM进入视窗时上报

可以看见,除了交互事件之外,其余事件貌似都可以自动上报,而无须在业务代码中插入埋点代码。结合业务,经过讨论后,最终决定采用声明式埋点,一方面减少埋点代码对业务代码的侵入,另一方面也减少了前端编写埋点代码的工作量

所谓声明式埋点,指的是通过配置描述页面需要上报哪些埋点,然后在对应的时机上报数据。由于我们的前端项目基于Vue开发,因此下面主要介绍在Vue中实现声明式埋点的方法。

3.1. 通过mixin处理pv和停留

对于pv和页面停留,最直观的感觉是在vue-router的导航钩子如afterEach中处理,由于单个route对象可以通过meta属性挂载额外参数,因此可以用来保存埋点的pageType字段

router.afterEach((to, from) => {
  // 上报当前页面的访问事件
  trackPvLog(to)

  // 上报上一个页面的停留事件
  trackDurationLog(from)
})

但与自动埋点类似,这种做法存在一些问题

  • 某些页面可能不希望上报数据
  • 某些页面存在重定向行为,或者需要执行某些异步逻辑之后才能算作真实的访问
  • 某些页面需要上报动态的参数,而根据route对象没法直接获取到这些参数

基于上面这些问题,最终决定通过mixin来处理pv和页面停留的上报

  • 路由组件提供了beforeRouteEnterbeforeRouteLeave等接口
  • 组件可以读取$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相关配置
      }
    },
  ]
})

3.2. 通过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()
    }
  }
}

3.3. 通过指令处理交互事件

在前面提到,交互事件需要在对应事件触发时上报,同样,为了避免手动调用接口,我们可以将逻辑封装成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获取当前页面配置的pageType
  • getLogParams方法,因此都支持从当前组件获取额外的动态参数_logExtend_logExtra

因此,通过自定义指令,无需再为交互事件编写埋点代码。

3.4. 小结

在整个数据收集方案中,通过声明尽可能地避免手动埋点,且在一定程度上保证埋点的灵活性,这样可以保证开发同学无须过度关心埋点代码,也能够正确上报数据

  • 在route配置的meta中声明埋点的基本配置项
  • 通过自定义指令上报交互事件

但不可避免的是,还是会存在在部分依赖手动上报的场景,如接口调用成功后的埋点等,需要提前建好维护好对应事件,然后手动埋点。在实际开发场景中,应尽量避免这种做法。

解决了数据收集的问题之后,我们来看一看数据上报的问题。

4. 数据上报方案

在文章开头场景中提到,我们需要解决的数据上报问题包括

  • 应该尽量保证在页面跳转或关闭前的埋点正常上报,而不是创建同步ajax或者setTimeout延迟页面跳转
  • 数据上报是整个页面优先级比较低的请求,不应该跟业务接口竞争请求资源

接下来讨论当前比较流行的前端数据上报方案。

4.1. 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上查看。为了向后兼容,我们还可以使用像素打点。

4.2. 像素埋点

所谓像素打点,就是构建一个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错误。

4.3. 小结

在新的方案中,优先使用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

5. 总结

本文整理了在本次埋点系统的重构中关于字段设计、数据收集和数据上报,

  • 按页面维度设计事件
  • 实现了一种在Vue项目中声明式收集数据的方案,这个方案理论上也是可以运用在React等单页项目中
  • 使用Beancon上报数据,向后兼容使用像素埋点

目前整个系统已经在试运行中,每天大概有几十万条数据,看起来还比较稳定,后续还可以补充日志存储方案和数据可视化展示,等有空了再完善一下。

5.1. 存在的问题

在项目运行了一段时间之后,发现通过logExtendlogExtra配置项声明公共参数也存在一些问题:

  • 当需要上报的事件分散在组件树的各个角落时,都需要声明公共参数,这也是十分繁琐和重复的工作
  • 此外声明式舍弃了原本的代码逻辑,不容易追踪和排查问题
  • 此外还有部分弹窗组件,是挂载在全局的body节点下面,并没有在当前路由组件树中,不一定能拿到对应的context获取公共参数

针对上面问题,经过实践目前的一些处理方案

对于跨层的组件公共参数

  • 状态提升到路由组件
  • 使用全局状态维护

对于脱离组件树的公共参数,

  • 通过props传入
  • 使用全局状态维护,如放在vuex中

感觉统一逻辑,放在store里面应该是最简单的,即通过vuex来触发上报行为,不过这感觉又回到了代码侵入式的埋点,还需要继续观察一段时间,再回头来总结具体方案。

实现一个Vue右键菜单指令在前端业务场景下的设计模式