埋点

关于埋点的系统介绍

埋点的用途

通过埋点可以将在使用客户端过程中的各种数据发送到日志文件、消息队列、数仓等存储介质中。技术同学可以基于这些数据进行数据挖掘、数据建模等工作,产出有价值的分析报告。另外,通过研发一些可视化工具,也可以让市场、产品、运营等人员自主、高效地进行业务分析。

埋点一般采集页面曝光、按钮点击、用户的交互行为等各种数据。它可以分析H5活动的效果、广告引流情况、产品新功能使用情况等等。它是基于数据分析业务的有力工具。

前端埋点sdk

前端埋点sdk一般会监听一些事件实现自动化埋点,同时也会封装一些api,并提供配置入口以供使用者调用,从而提高埋点的效率以及数据的规范性。

根据sdk设计的基础理论需要思考:

  1. who:是谁操作,有哪些属性
  2. when:何时触发
  3. where:触发位置
  4. what:具体内容

前端埋点sdk一般会采集页面浏览、点击、页面加载性能、JS报错等数据

埋点方案

埋点一般有5种方案:

  1. 代码埋点
  2. 声明式埋点
  3. 可视化埋点
  4. 无埋点
  5. 后端埋点

    代码埋点

    概念:在需要埋点的地方手动写埋点代码。
    优点:
  6. 自定义程度高
  7. 可以实现部分其他埋点方式难以实现的功能。

缺点:

  1. 容易与业务逻辑耦合
  2. 埋点成本高
  3. 如果代码逻辑改动,埋点可能会受影响
  4. 存在覆盖问题。埋点更新,用户并未更新代码,则用户仍使用的是旧的埋点逻辑。

    声明式埋点

    概念:在相应元素上加标记,该元素相应操作就会发送埋点
    优点:
  5. 和业务逻辑解耦
  6. 开发量小
    缺点:
  7. 特有的一些埋点功能无法实现

    可视化埋点

    概念:通过在界面上可视化的操作与埋点事件发生联系。

优点:

  1. 埋点效率高
  2. 与业务逻辑解耦
  3. 易实现埋点数据的标准化
  4. 即时发布

缺点:

  1. 功能有限

    无埋点

    无埋点其实是全埋点
    概念:通过绑定事件,自动发送埋点数据。
    优点:
  2. 埋点成本低

缺点:

  1. 会增加通信商流量,后端有一定传输压力
  2. 数据虽多却杂,需要分析处理。

后端埋点

概念:在后端采集数据,如采集日志。
优点:

  1. 数据传输稳定
    缺点:
  2. 功能有限

埋点平台化、系统化

埋点需要在系统层面做规划,其中包括埋点规范制定、埋点管理、数据测试、监控等。只有使埋点平台化、系统化,多种埋点方式相结合,才能充分满足业务需求。

[干货]实现一个无埋点和可视化埋点的sdk

序言

本文结合自身项目中的一些实践,将无埋点及可视化埋点的实现原理部分抽象整理出了一个sdk。同时也查阅了许多相关资料,发现其实它们在无埋点的实现原理上其实大同小异。

sdk仅介绍和实现了点击事件的无埋点,其他用户行为的埋点也相类似。
sdk github地址 https://github.com/mfaying/web-log-sdk

无埋点

无埋点实际是全埋点,只要嵌入sdk,就可以自动收集数据。由于不再需要额外的埋点代码,所以也可以称为无埋点。

演示

首先,让我们先来看下sdk的演示效果体验网址 (https://www.readingblog.cn/#/tutorials/circle-select)

父页面(埋点管理页面)嵌入了一个iframe,指向了一个子页面(嵌入sdk的埋点页面),sdk可以自动计算点击元素的唯一标识(XPath),以及元素大小、位置等相关信息,将数据发送给后端。同时,也会将这个数据跨域发送给埋点管理页面,管理页面依据这些数据做可视化埋点工作。图中,管理页面可以获取到了元素的信息(包括大小、位置、XPath等)。

如何使用

sdk的使用方式非常简单
首先,在head标签中引入sdk代码

1
<script src="https://www.readingblog.cn/web-log-sdk-1.0.0.min.js"></script>

然后,初始化sdk,在初始化时你可以传入一些自定义参数。初始化完毕后,sdk就已经在你的页面中工作了,是不是很方便!

1
2
3
new WebLogger.AutoLogger({
debug: true,
});

这里是一个简单demo页面,在浏览器打开这个页面。随意点击,每次点击可以在控制台中看到自动打印出的埋点数据。

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>web-log-sdk</title>
<script src="https://www.readingblog.cn/web-log-sdk-1.0.0.min.js"></script>
<script>
new WebLogger.AutoLogger({
debug: true,
});
</script>
</head>
<body>
<div>
1
<div id='1'>
2
<div id="1">3</div>
<div>4</div>
</div>
</div>
<div>5</div>
</body>
</html>

无埋点的原理

无埋点其实监听了document.body上的点击事件。所以页面上的所有点击操作都会发送埋点数据。

1
2
3
_autoClickCollection = () => {
event.on(doc.body, 'click', this._autoClickHandle);
}

这里就出现了一个问题,虽然这样点击操作能够触发埋点数据发送,但是我们必须确保发送的数据是有价值的。
这里最关键的是我们需要知道是页面中的哪个元素触发了用户的点击操作。由于是自动埋点,我们必须思考一种页面元素的标记方式。虽然元素有class、nodeName等标识,但这对于整个页面来说是无法唯一定位一个元素的。元素的id虽然按照规范是唯一的,但也只有个别元素会标记上id属性。
所以我们想了一种方式,由于整个html的dom结构像一棵树,对于任意元素(节点),我们先找到它的父节点,父节点再找它的父节点,这样一直回溯,就会到html(根节点元素),这样就组成了一条路径,我们将这条路径作为元素的唯一标识。当然了,如果的“XPath”反转一下,由“从父到子”的顺序排列,例如html>body>#app。这样我们通过document.querySelector就可以唯一选中这个被点击的元素了。
具体实现如下:

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
const _getLocalNamePath = (elm) => {
const XPath = [];
let preCount = 0;
for (let sib = elm.previousSibling; sib; sib = sib.previousSibling) {
if (sib.localName == elm.localName) preCount ++;
}
if (preCount === 0) {
XPath.unshift(elm.localName);
} else {
XPath.unshift(`${elm.localName}:nth-of-type(${preCount + 1})`);
}
return XPath;
}

const getXPath = (elm) => {
try {
const allNodes = document.getElementsByTagName('*');
let XPath = [];
for (; elm && elm.nodeType == 1; elm = elm.parentNode) {
if (elm.hasAttribute('id')) {
let uniqueIdCount = 0
for (var n = 0; n < allNodes.length; n++) {
if (allNodes[n].hasAttribute('id') && allNodes[n].id == elm.id) uniqueIdCount++;
if (uniqueIdCount > 1) break;
}
if (uniqueIdCount == 1) {
XPath.unshift(`#${elm.getAttribute('id')}`);
} else {
XPath.unshift(..._getLocalNamePath(elm));
}
} else {
XPath.unshift(..._getLocalNamePath(elm));
}
}
return XPath.length ? XPath.join('>') : null
} catch (err) {
console.log(err)
return null;
}
}

export default getXPath;

代码中我们还做一些处理,比如当有多个localName相同的兄弟节点时,常见的例如

1
2
3
4
5
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>

我们通过:nth-of-type选择器来区分。

如果有id属性,为了确保id是唯一的(规范要求必须唯一,但开发者也有可能会在无意间赋上重复的id属性),我们做了检查,如果是唯一的就使用id作为标记,这样可以提高选择器的效率。

确定了元素的唯一标识,接下来的事情就很简单了。我们只需获取所需要的埋点数据,将其发送给后端就可以了。

比如获取元素位置信息

1
2
3
4
5
6
7
8
9
10
11
12
13
const getBoundingClientRect = (elm) => {
const rect = elm.getBoundingClientRect();
const width = rect.width || rect.right - rect.left;
const height = rect.height || rect.bottom - rect.top;
return {
width,
height,
left: rect.left,
top: rect.top,
};
}

export default getBoundingClientRect;

获取平台信息

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
import { ua } from '../common/bom';
import platform from 'platform';

const getPlatform = () => {
const platformInfo = {};

platformInfo.os = `${platform.os.family} ${platform.os.version}` || '';
platformInfo.bn = platform.name || '';
platformInfo.bv = platform.version || '';
platformInfo.bl = platform.layout || '';
platformInfo.bd = platform.description || '';

const wechatInfo = ua.match(/MicroMessenger\/([\d\.]+)/i);
const wechatNetType = ua.match(/NetType\/([\w\.]+)/i);
if (wechatInfo) {
platformInfo.mmv = wechatInfo[1] || '';
}
if (wechatNetType) {
platformInfo.net = wechatNetType[1] || '';
}

return platformInfo;
}

export default getPlatform;

当前url、引用url、title、事件的触发时刻等等信息都可以补充进去。这是我的sdk发送的一个埋点数据

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
{
"eventData": {
"et": "click",
"ed": "auto_click",
"text": "参考: Elasticsear...icsearch 2.x 版本",
"nodeName": "p",
"XPath": "html>body>#app>section>section>main>div:nth-of-type(5)>div>p>p",
"offsetX": "0.768987",
"offsetY": "0.333333",
"pageX": 263,
"pageY": 167,
"scrollX": 0,
"scrollY": 0,
"left": 20,
"top": 153,
"width": 316,
"height": 42,
"rUrl": "http://localhost:8080/",
"docTitle": "blog",
"cUrl": "http://localhost:8080/#/blog/article/74",
"t": 1573987603156
},
"optParams": {},
"platform": {
"os": "Android 6.0",
"bn": "Chrome Mobile",
"bv": "77.0.3865.120",
"bl": "Blink",
"bd": "Chrome Mobile 77.0.3865.120 on Google Nexus 5 (Android 6.0)"
},
"appID": "",
"sdk": {
"type": "js",
"version": "1.0.0"
}
}

实现可视化圈选埋点

可视化埋点一般会使用iframe将埋点页面嵌入。这时子页面是埋点页面(由iframe引入)、父页面是管理页面。由于iframe的src属性是支持跨域加载资源的,所以任何埋点页面都是可以嵌入的。

但是要实现圈选功能,必须实现埋点页面和管理页面的通信,因为管理页面是不知道埋点信息的。而且由于埋点页面是跨域的,管理页面根本无法操作埋点页面。

这里我们就需要sdk实现一种通信机制了,我们采用通用的跨域通信方案postMessage。
在sdk的配置项中增加一个postMsgOpts字段用来配置postMessage参数,postMsgOpts的默认值是一个空数组,也就是说它可以允许埋点页面向多个源发送数据,而它的默认配置是不会通过postMessage发送数据的。
postMsgOpts字段配置示例如下:

1
2
3
4
5
6
7
8
9
10
new AutoLogger({
debug: true,
postMsgOpts: [{
targetWindow: window.parent,
targetOrigin,
}, {
targetWindow: window,
targetOrigin: curOrigin,
}],
});

这样将要发送的埋点数据也会调用postMessage api发送一份。

1
2
3
4
postMsgOpts.forEach((opt) => {
const { targetWindow, targetOrigin } = opt;
targetWindow.postMessage({ logData: JSON.stringify(logData) }, targetOrigin)
});

我们回过头来分析演示是如何实现可视化埋点的。首先管理页面的iframe加载了埋点页面,由于埋点页面引入了sdk,所以点击页面中任何元素,都会将埋点数据通过postMessage发送一份给管理页面。这里的数据包括了元素的大小和位置、XPath等等。管理页面只要监听了”message”事件,就可以拿到从子页面(埋点页面)传出来的数据了。为了交互友好,根据这些信息管理页面可以圈出iframe中选中的元素。当然了,只要管理页面拿到了埋点数据,就可以在这基础上和使用管理页面的用户交互,做一些自主配置同时将附加信息及选中元素的信息传递给后端,这样后端就可以对选中元素做处理了,从而实现可视化埋点。

配置项

最后介绍一下我的sdk的配置项,先参考一下默认配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import getPlatform from '../../utils/getPlatform';

const platform = getPlatform();

export default {
appID: '',
// 是否自动收集点击事件
autoClick: true,
debug: false,
logUrl: '',
sdk: {
// 类型
type: 'js',
// 版本
version: SDK_VERSION,
},
// 平台参数
platform,
optParams: {},
postMsgOpts: [],
};

  1. appID 你可以在初始化时注册一个appID,所以相关的埋点都会带上这个标记,相当于对埋点数据做了一层app维度上的管理。
  2. autoClick 默认为true,开启会自动收集点击事件(即点击无埋点)。当然你可以实现页面登录、登出、浏览时间的埋点功能,同时可以在配置中加开关控制,让用户可以有选择地启用这些功能。
  3. debug 默认不开启,开启会将埋点数据打印到控制台,便于调试。
  4. logUrl 接收日志的后端地址
  5. sdk sdk自身信息一些说明
  6. platform 默认会自动获取一些平台参数,你也可以通过配置这个字段覆盖它
  7. optParams 自定义数据

growing.io圈选及热力图功能的实现

growing.io圈选及热力图功能还是比较好用的,这周调研了它的技术实现,发现其实并不复杂。

演示

sdk

之前自己实现了一个无埋点sdk(https://github.com/mfaying/web-log-sdk)
也介绍了无埋点的实现原理(https://juejin.im/post/5dd158d46fb9a01fff5e7499)
现在我们在这个sdk的基础上增加圈选及热力图功能。

通信

由于sdk页面在埋点系统中会嵌入在一个iframe里,所以我们必须使sdk具备跨域通信的能力。这里我们使用的是postMessage,sdk会监听埋点系统发送的消息,来决定是否开启圈选或热力图模式。

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
_addMessageListener = () => {
eventUtil.on(win, 'message', (event) => {
if(event.data){
try {
const data = JSON.parse(event.data);
const { mode, status } = data;
if (mode === MODE.CIRCLE_SELECT) {
if (status === 'on') {
this.mode = mode;
this._autoHoverCollection();
this._appendWLSStyle();
this._removeHeatmapCanvas();
} else if (status === 'off') {
this.mode = '';
this._autoHoverCollectionOff();
this._removeWLSStyle();
}
} else if (mode === MODE.HEATMAP) {
if (status === 'on') {
this.mode = mode;
this._autoHoverCollection();
this._appendWLSStyle();
this._fetchHeatmap().then((res) => {
this._drawHeatmap(res.data.data);
});
} else if (status === 'off') {
this.mode = '';
this._autoHoverCollectionOff();
this._removeWLSStyle();
this._removeHeatmapCanvas();
}
}
} catch (e) {
console.log(e);
}
}
})
}

圈选模式

当开启圈选模式时,sdk会自动采集”hover事件”,在页面中插入一段css(元素被圈选时会加上圈选类名,这段css就是圈选类名的样式)

1
2
3
4
5
6
if (status === 'on') {
this.mode = mode;
this._autoHoverCollection();
this._appendWLSStyle();
this._removeHeatmapCanvas();
}

元素hover时会增加一个圈选类名,并阻止页面跳转等默认事件,同时将数据发送给埋点平台。
埋点平台接收到这个数据就可以自主做圈选分析了。

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
_autoHoverHandle = (e) => {
try {
const { event, targetElement } = getEvent(e);
const assignData = {
et: 'mouseenter',
ed: 'auto_hover',
}
const logData = this._getLogData(e, assignData);
if (this.mode === MODE.CIRCLE_SELECT || this.mode === MODE.HEATMAP) {
this._selectElement(event, targetElement);
this._postMessage(logData);
}
} catch (err) {
console.log(err);
}
}

_selectElement = (event, targetElement) => {
const elems = doc.getElementsByClassName(WLS_CLICK_SELECT);
for (let i = 0, len = elems.length; i < len; i ++) {
elems[i].classList.remove(WLS_CLICK_SELECT);
}
eventUtil.stopDefault(event);
targetElement.classList.add(WLS_CLICK_SELECT);
}

热力图

当开启热力图时会向用户自己配置的heatmapUrl请求当前页面的热力图数据,绘制热力图。

1
2
3
4
5
6
7
8
if (status === 'on') {
this.mode = mode;
this._autoHoverCollection();
this._appendWLSStyle();
this._fetchHeatmap().then((res) => {
this._drawHeatmap(res.data.data);
});
}

0%