郁郁青青 长过千寻

数据埋点(二)

    笔记本

    1. 场景
    2. 做法
      1. 埋点的内容
      2. 埋点的执行
    3. 解释
      1. 组件 clicker-tracking
        1. Props
      2. 组件 viewer-tracking
        1. Props
      3. 函数 sendEventTagByDomId 和 sendEventTagByHardId
    4. 注意
    5. 其它
      1. 组件 viewer-tracking 的实现
      2. 组件 viewer-tracking 的 vue 实现
      3. 组件 viewer-tracking 内 isBinded 的实现
      4. 组织结构和生成结果
      5. 存放埋点内容文件

这是我在旺仔俱乐部小程序上的埋点实践,小程序使用uniapp开发。

场景

埋点场景:

  • 按钮点击;
  • 弹窗进入、退出;
  • 页面进入、离开、停留;
  • 页面分享出去、分享进入;
  • 程序逻辑。

我在俱乐部碰到的埋点可以归结成上面几种场景,下面对每种场景做简单举例。

按钮点击:登录按钮的点击埋点,不需要知道用户是否同意或拒绝登录,仅点击即埋点;广告 banner 的点击埋点;底部导航栏的点击埋点。

弹窗进入、退出:首次进入时首页的弹窗;扫码出现的弹窗。

页面进入、离开、停留:每个页面的进入退出停留埋点;1. 进入首页进行首页进入埋点;2. 点击“会员码”页面,进行首页离开埋点;3. 同时“会员码”页面进行进入埋点;4. 停留在“会员码”页面后每 10s 进行一次停留埋点。

页面分享出去、分享进入埋点:点击分享进行埋点,分享进入链接会携带分享参数,拿到参数进行分享进入埋点。

程序逻辑:代码执行到某种条件内进行埋点,如果这种条件通过点击触发可以反映到 ui 上,又可以转换为第一种按钮点击的情况;如果埋点是在后端接口的 api 中,这种埋点就更适合服务端执行。

做法

埋点的内容

埋点的内容保存在一个或多个文件中,埋点的组件或者函数会在被执行的时候从这些文件中找到具体的埋点内容。

可以根据埋点规模和团队习惯来确定埋点内容的保存方式,是分散在每一个埋点处,还是提取到一个文件中,还是分类至多个文件中。

俱乐部使用的保存方式是把埋点内容分类至多个文件中。

埋点的执行

我的实践中具体进行埋点的时候会涉及到的组件和函数:

组件:clicker-trackingviewer-tracking

函数:sendEventTagByDomIdsendEventTagByHardId

使用说明:

  • 组件clicker-tracking包裹在按钮组件外,点击进行按钮点击埋点;
  • 组件viewer-tracking放在弹窗组件内,弹窗展示进行弹窗进入埋点,弹窗关闭进行退出埋点;
  • 组件viewer-tracking放在页面中,进行页面进入、离开、停留埋点;
  • 点击分享的时候在程序中调用sendEventTagByHardId埋点,分享进入的时候检测链接的分享参数,调用sendEventTagByHardId 进行分享进入埋点;
  • 程序逻辑中调用sendEventTagByHardId进行埋点。

解释

组件 clicker-tracking

组件clicker-trackingvue实现放在最后。

组件套在按钮上的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
<clicker-tracking
:eid="eventId"
:scope="eventScopeCode"
:data="{
snackBtnName: eventBtnName,
}">
<button
@click="goSnackCenter"
class="clean_btn btn_icon"
:style="{
backgroundImage: 'url(' + iconImg + ')',
}" />
</clicker-tracking>

可以看到我要向组件传递 3 个参数,eidscope来定位具体的埋点内容,data用来传递埋点内容需要的变量,这个例子里需要的是按钮的名称,这里按钮的名称是根据状态改变的,所以传递了eventBtnName这个变量。

Props

Name Type Default isRequired Description
scope string / 用于查找埋点内容
eid string / 用于查找埋点内容
data Record<string, unknown> / 🔴 传递埋点内容的变量

组件 viewer-tracking

组件viewer-trackingvue实现放在最后。

组件放在页面中的样子:

1
<viewer-tracking :sharedData="{ pageName: '每日签到' }" />

这里的作用是“每日签到”页面的埋点,进入埋点、离开埋点和停留埋点。

如果不想要停留埋点,就像这样:

1
<viewer-tracking :sharedData="{ pageName: '每日签到' }" disableStay />

从组件的props可以看到,和组件clicker-tracking差不多,也使用scopeenterIdstayIdleaveId来定位具体的埋点内容,enterDatastayDataleaveData用来传递埋点内容需要的变量,而且有disable属性用来控制关闭进入、停留或离开埋点,这是为了适应有的页面不用记录停留埋点的需求。(代码块中的sharedDataenterDatastayDataleaveData的封装,最后的组件实现中有解释。)

这个组件还有些特殊的地方,它的执行总会跟随组件的绑定状态。页面进入和离开可以看成是页面的绑定解绑,弹窗的弹出和关闭可以看成是弹窗的绑定解绑,所以这个组件要可以监听到这种状态。这就是组件里watchisBinded的作用,还有生命周期mounted beforeDestroy的作用,isBinded具体的实现我放在最后。

Props

Name Type Default isRequired Description
scope string VIEWER 🔴 用于查找埋点内容,浏览埋点通常都有默认相同的结构,所以设置默认值
enterId string enter 🔴 用于查找埋点内容,浏览埋点通常都有默认相同的结构,所以设置默认值
stayId string stay 🔴 用于查找埋点内容,浏览埋点通常都有默认相同的结构,所以设置默认值
leaveId string leave 🔴 用于查找埋点内容,浏览埋点通常都有默认相同的结构,所以设置默认值
enterData Record<string, unknown> / 🔴 传递埋点内容(进入)的变量
stayData Record<string, unknown> / 🔴 传递埋点内容(停留)的变量
leaveData Record<string, unknown> / 🔴 传递埋点内容(离开)的变量
sharedData Record<string, unknown> / 🔴 传递埋点内容的变量,这个组件通常会在一个页面里,进入、停留、离开的埋点需要的变量也是相同的,这个属性就是相同的部分,设置之后就不用分别为三个埋点设置埋点内容的变量了
disableEnter boolean false 🔴 关闭进入埋点
disableStay boolean false 🔴 关闭停留埋点
disableLeave boolean false 🔴 关闭离开埋点

函数 sendEventTagByDomId 和 sendEventTagByHardId

如果我们用有逻辑和无逻辑划分页面,逻辑脚本是属于有逻辑的,页面结构是属于无逻辑的。组件来配合页面结构埋点,而函数就配合逻辑脚本进行埋点。

sendEventTagByDomIdsendEventTagByDomId进行埋点时要传递的参数和组件属性差不多,就像这样:

1
2
3
4
5
6
7
8
this.sendEventTagByDomId({
e,
scope: SC.LOGIN_BY_PHONE_BTN,
data: {
prevPageTitle: this.prevPageTitle,
btnName: '立即登录',
},
})
1
2
3
4
5
6
7
8
this.sendEventTagByHardId({
id: 'login_back',
scope: SC.LOGIN_BY_PHONE_BTN,
data: {
prevPageTitle: this.prevPageTitle,
btnName: '返回',
}
})

它们都需要scopeide来定位具体的埋点内容,data来传递埋点内容需要的变量。两个函数不同的是函数名称和入参eid,从名称中的DomIdHardId能看出来,一个和页面 DOM 有关,另一个和硬编码的 id 有关,下面是sendEventTagByDomId放在程序环境中的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 省略非埋点内容......
// === 结构部分 ===
<view class="func_wrapper" id="login_btn" @click.capture="track($event)">
<get-phone-number-vue v-if="agreedPolicies" @todoAfterLoggedIn="toDoAfterLoggedIn">
<view slot="content" class="btn_login red">立即登录</view>
</get-phone-number-vue>
<button v-else class="btn_login red" @click="showPolicyToast">立即登录</button>
</view>
// 省略非埋点内容......
// === 脚本部分 ===
track(e) {
this.sendEventTagByDomId({
e,
scope: SC.LOGIN_BY_PHONE_BTN,
data: {
prevPageTitle: this.prevPageTitle,
btnName: '立即登录',
},
})
}
// 省略非埋点内容......

可以看到标签上的id属性,函数sendEventTagByDomId的入参e取的就是这个idsendEventTagByDomId最终会被转化为sendEventTagByHardId,就像这样:

1
2
3
4
function _sendEventTagByDomId(sendEventTagByHardId, { scope, e, data }) {
const id = e.currentTarget.id
sendEventTagByHardId({ scope, id, data })
}

注意

埋点的请求不应该占用太多资源,浏览器有请求数量限制,所以不可以让埋点请求挂起太长时间,导致真正的业务请求被阻塞。可以设置 1s 后不返回就关闭请求,也可以设置埋点的请求队列,推迟,在不再有业务请求的空闲时进行埋点。

埋点抛出的错误不能停止程序的运行,要在内部捕获错误。

埋点的请求可以不用携带cookiesession等和业务有关的请求头信息,结合上面的注意点,另外封装埋点的 http 请求函数会更方便。

不应该让这些规则复杂,停止炫技,不能让团队成员感到困惑。

其它

组件 viewer-tracking 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<template>
<view @click.capture="trackIt">
<slot />
</view>
</template>

<script>
import { mapActions } from 'vuex'
/**点击打点组件 */
export default {
props: {
/**对应了`scope-code.js`里的值 */
scope: {
type: String,
default: '',
},
/**每个打点内容的 id,在`libs/tracking/event-tracking-config/xxx/yyy.js`里定义 */
eid: {
type: String,
default: '',
},
/**每个打点的内容,在`libs/tracking/event-tracking-config/xxx/yyy.js`里定义 */
data: {
type: Object,
default: {},
}
},
methods: {
...mapActions(['sendEventTagByHardId']),
trackIt() {
this.sendEventTagByHardId({
scope: this.scope,
id: this.eid,
data: this.data,
})
},
},
}
</script>

组件 viewer-tracking 的 vue 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
<template>
<view />
</template>

<script>
import { mapActions } from 'vuex'
import SC from '@/libs/tracking/scope-code'
import { timeFormatter } from '@/libs/tracking/tracking-helper'
/**页面浏览打点组件 */
export default {
name: 'ViewerTracking',
data() {
return {
pulseTimer: null,
curPagePath: null,
hasEntered: false,
hasLeft: false,
isAlive: false,
}
},
props: {
scope: {
type: String,
default: SC.VIEWER,
},
enterId: {
type: String,
default: 'enter',
},
stayId: {
type: String,
default: 'stay',
},
leaveId: {
type: String,
default: 'leave',
},
enterData: {
type: Object,
default: {},
},
stayData: {
type: Object,
default: {},
},
leaveData: {
type: Object,
default: {},
},
/**enterData、stayData、leaveData 公有的部分 */
sharedData: {
type: Object,
default: {},
},
// 关闭进入页面的打点
disableEnter: {
type: Boolean,
default: false,
},
// 关闭页面停留的打点
disableStay: {
type: Boolean,
default: false,
},
// 关闭页面离开的打点
disableLeave: {
type: Boolean,
default: false,
},
},
computed: {
isBinded() {
return this.$viewerTracking.isBinded(this.curPagePath)
},
},
watch: {
isBinded(v) {
if (v) {
// console.log('组件内:进入了 ' + this.curPagePath + ' 页面')
// 进入页面
if (! this.disableEnter) {
// 进入页面打点
this.sendEnterEvent()
}
if (! this.disableStay) {
// 开始心跳打点
this.pulse()
}
} else {
// console.log('组件内:离开了 ' + this.curPagePath + ' 页面')
// 离开页面
if (! this.disableLeave) {
// 离开页面打点
this.sendLeaveEvent()
}
if (! this.disableStay) {
// 停止心跳打点
this.kill()
}
}
}
},
beforeMount() {
// 获取当前页面路径
this.curPagePath = (getCurrentPages().slice(-1)[0] || {}).route
},
mounted() {
// 第一次打点等待组件挂载完打点,因为父组件可能会使用
// v-if 挂载组件,这样组件就可能无法 watch 到页面
// onshow 的变化。
// 比如用户扫码跳转到某个页面,这时首页无需打点,将会用
// v-if 不挂载该组件,但是如果没有扫码,首页需要打点,
// v-if 由默认false 转为 true,而此时也错过了 onshow
// 的时机,就无法通过 onshow 进行页面的进入和心跳打点,
// 这时就用组件挂载进行页面的进入和心跳打点。
if (! this.disableEnter) {
// 进入页面打点
this.sendEnterEvent()
}
if (! this.disableStay) {
// 开始心跳打点
this.pulse()
}
},
beforeDestroy() {
// console.log('组件内:离开了 ' + this.curPagePath + ' 页面')
// 离开页面
if (! this.disableLeave) {
// 离开页面打点
this.sendLeaveEvent()
}
if (! this.disableStay) {
// 停止心跳打点
this.kill()
}
},
destroyed() {
},
methods: {
...mapActions(['sendEventTagByHardId']),
/**开始心跳打点 */
pulse() {
if (this.isAlive) { return }
this.isAlive = true
let intervalTime = 10000
let accTime = 0
this.pulseTimer = setInterval(() => {
accTime += 10
this.sendEventTagByHardId({
scope: SC.VIEWER,
id: this.stayId,
data: {
...this.sharedData,
...this.stayData,
stayTime: accTime + 's',
}
})
}, intervalTime)
},
/**停止心跳打点 */
kill() {
this.isAlive = false
clearInterval(this.pulseTimer)
},
/**进入页面打点 */
sendEnterEvent() {
if (this.hasEntered) { return }
this.hasEntered = true
this.hasLeft = false
this.sendEventTagByHardId({
scope: SC.VIEWER,
id: this.enterId,
data: {
...this.sharedData,
...this.enterData,
enterTime: timeFormatter()
}
})
},
/**离开页面打点 */
sendLeaveEvent() {
if (this.hasLeft) { return }
this.hasLeft = true
this.hasEntered = false
this.sendEventTagByHardId({
scope: SC.VIEWER,
id: this.leaveId,
data: {
...this.sharedData,
...this.leaveData,
leaveTime: timeFormatter()
}
})
}
}
}
</script>

组件 viewer-tracking 内 isBinded 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// @/src/libs/tracking/tracking-store.js
import Vue from 'vue'

// 页面是否进入或离开,是用于打浏览打点的标记
// 如果 binded['_pages/index/index'] 为 true,则
// 首页是进入状态,打点组件会检查这个属性,进行进入页面
// 的浏览打点,反之为 false,则首页是离开状态,组件会
// 进行离开页面的浏览打点。
const viewerTracking = Vue.observable({
binded: {},
bind(path) {
viewerTracking.binded = {
...viewerTracking.binded,
['_' + path]: true,
}
},
unbind(path) {
viewerTracking.binded = {
...viewerTracking.binded,
['_' + path]: false,
}
},
isBinded(path) {
return !! viewerTracking.binded['_' + path]
},
specPageBinded(path) {
return viewerTracking.binded['_' + path]
},
})

export default viewerTracking
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// @/src/main.js
import viewerTracking from '@/libs/tracking/tracking-store'

Vue.prototype.$viewerTracking = viewerTracking

Vue.mixin({
onShow() {
// 用于打进入浏览打点的标记
const path = (getCurrentPages().slice(-1)[0] || {}).route
// console.log('全局 mixin:展示 ' + path)
viewerTracking.bind(path)
},
onHide() {
// 用于打离开浏览打点的标记
const path = (getCurrentPages().slice(-1)[0] || {}).route
// console.log('全局 mixin:隐藏 ' + path)
viewerTracking.unbind(path)
},
onUnload() {
// 用于打离开浏览打点的标记
// 离开页面有两种情况,页面进入后台,或者点击左上角返回到上一页(导致当前页面卸载),onshow 和 onunload
const path = (getCurrentPages().slice(-1)[0] || {}).route
// console.log('全局 mixin:卸载 ' + path)
viewerTracking.unbind(path)
},
})

组织结构和生成结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import SCOPE_CONFIG_MAPPER from "./scope-config-mapper"
import { verifier as verifyId } from './tracking-helper'
import { reqeustTrackChannel } from '@/libs/tracking/tracking-helper'

/**
* 生成事件打点标签的 aid 和 a_attrs 属性
* @param {String} scopeCode 域代码,对应 scope-code.js 里的数据
* @param {String} id 打点 id
* @param {Object} data 打点事件标签需要的数据,每个打点不一样,根据打点文档设置
* @param {String} adid 记录用户行为的字符串,由前端硬编码或者从服务端索取
* @param {*} trackBePlatform 参与渠道
* @returns {Object} { aid: 'xxx', a_attrs: {} }
*/
export default async function genEventTrackingCore(scopeCode, id, data, adid, trackBePlatform) {
if (scopeCode == null || scopeCode === '') {
throw('打点错误:' + '缺少 scopeCode。')
}
const { pageObj } = data // 后端返回的配置跳转 page
const pageStr = (pageObj && await reqeustTrackChannel(pageObj)) || undefined
const scopeConfigMapper = SCOPE_CONFIG_MAPPER
const gotTrackingList = scopeConfigMapper.get(scopeCode)
if (gotTrackingList == null) {
throw('打点错误:' + `通过 scopeCode“${scopeCode}”找不到对应的打点列表,请检查文件“scope-config-mapper.js”中有没有做 scopeCode“${scopeCode}”和打点列表的关联。`)
}
const loadedTrackingList = gotTrackingList({ ...data, adid, trackBePlatform, pageStr })
const loadedTrackingItem = loadedTrackingList.find(item => verifyId(item.id, id))
if (loadedTrackingItem == null) {
throw('打点错误:' + `通过 scopeCode“${scopeCode}”和 id“${id}”找不到对应的打点内容。`)
}
const gotConfig = loadedTrackingItem.config
return gotConfig
}

存放埋点内容文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
export default ({
trackBePlatform, adid, enterTime, leaveTime, stayTime, pageName,
}) => [{
id: 'enter',
config: {
aid: '页面浏览',
a_attrs: {
adid: adid,
'参与渠道': trackBePlatform,
'项目名称': '旺仔俱乐部',
'页面名称': pageName,
'进入时间': enterTime
}
}
}, {
id: 'leave',
config: {
aid: '页面浏览',
a_attrs: {
adid: adid,
'参与渠道': trackBePlatform,
'项目名称': '旺仔俱乐部',
'页面名称': pageName,
'离开时间': leaveTime,
}
}
}, {
id: 'stay',
config: {
aid: '页面停留',
a_attrs: {
adid: adid,
'参与渠道': trackBePlatform,
'项目名称': '旺仔俱乐部',
'页面名称': pageName,
'停留时间': stayTime,
}
},
},]
页阅读量:  ・  站访问量:  ・  站访客数: