【第2133期】如何搭建一套 “无痕埋点” 体系?

作者:聂风

前言

今日前端早读课文章由涂鸦智能@聂风投稿分享。

@王立康,花名聂风,目前任职于涂鸦智能大前端-BI大数据组,主要负责数据及可视化研发工作。

正文从这开始~~

本文主要讲解如何搭建一套 无痕埋点 数据采集体系。

需求背景介绍

利用数据分析可以做的事情太多了,引用一句话:“数据是工业互联网的基石”。

话不多说,开始吧

为实现埋点数据采集,我们得写一段 js 脚本(sdk),让前端系统接入,将采集到的数据无感知的上报到我们的服务器。

首先,将任务进行分解一下:

前端接入埋点 js 脚本

我们需要在前端系统中插入一段 script 标签,参考以下代码:

  1. <script>

  2. (function(f, c, d, e, a, b) {

  3. a = c.createElement(d);

  4. b = c.getElementsByTagName(d)[0];

  5. a.async = 1;

  6. a.src = e;

  7. b.parentNode.insertBefore(a, b);

  8. })(

  9. window,

  10. document,

  11. 'script',

  12. 'https://static1.tuyacn.com/static/ty-lib/tpm3/tpm-x.x.x.min.js'

  13. );

  14. </script>

通过执行这段 script 代码,再以 script 标签的形式,往 dom 中插入真实的 js 代码,里面写一个立即执行函数就行了。那么,我们在这个立即执行函数里写我们要处理的逻辑,就完成了相应的前端 埋点(网页投毒)。那么,我们就正式开始介绍如何实现这段 js。

埋点需要收集哪些数据

首先我们要确认好要收集哪些数据,才可以满足我们的业务需求。以下,我们将数据分为三类:页面信息数据、浏览器数据、用户自定义数据。

1. 页面信息数据

这类数据可以是页面来源、当前站点语言、可视窗口大小、页面加载性能数据等等。

这些数据主要来自浏览器的 window 对象、上一个页面带过来的数据、以及按规则存储在 cookie 中的值。

2. 浏览器数据

这类数据主要有浏览器屏幕尺寸,浏览器类型,浏览器版本等信息。

3. 用户自定义数据

例如,开发者将标志符 "login.click" 表示点击了登录按钮,"login.success" 表示登录成功,"login.fail" 表示登录失败,将这些数据进行上报。这个例子中,有的可以数据可以通过监听 dom 事件的方式拿到,有的数据需要用户手动执行触发,这些都属于用户自定义数据,主要以下面两种方式上报:

第一种:将 "login.click" 埋在 dom 节点上,埋点 js 监听 dom 节点的点击事件,用户点击到该 dom 时,js 解析该 dom 上的数据,获取然后上报。

第二种:需要开发者在获得接口请求结果后将 "login.success" 或 "login.fail" 通过调用埋点 js 暴露出去的 API 触发事件,然后上报。

总之,这类数据是来自 开发者定义 的,也称 代码埋点。除了代码埋点以外,为了让开发者只关心业务逻辑实现,并且更加高效快捷、自动化埋点,还额外增加了 可视化埋点、无痕埋点 两种方式。

实现前端埋点3种技术方案

1. 代码埋点

代码埋点需要开发者在埋点的节点处插入埋点代码,例如点击事件的回调、元素的展示回调方法、页面的生命周期函数等等。

代码埋点的第一种方式,将代码埋点埋在 dom 上

  1. // 代码示例

  2. ...

  3. <span data-tpm='vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0' data-tpm-args='{"pid":1, "uid":2}'>登录</span>

  4. ...

此时,当有点击事件发生在该 dom 上时,js 对其进行解析:

  1. /**

  2. * js 将代码埋点事件解析上报到 nginx

  3. */


  4. // 第一步:将全局事件监听挂载在 body 上

  5. document.body.addEventListener('click', function(e) {

  6. ...

  7. // 第二步:获取目标元素(这里可以过滤掉点击到 body 的脏数据)

  8. const el = e.target;


  9. // 第三步:获取属性值

  10. const dataTpm = getNodeAttr(el, 'data-tpm'); // vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0

  11. const dataTpmArgs = getNodeAttr(el, 'data-tpm-args'); // {"pid":1, "uid":2}


  12. if (dataTpm || dataTpmArgs) {

  13. // 第四步:将获取到的数据上报到 nginx,产生一条日志

  14. const data = {

  15. type: 'click',

  16. ec: dataTpm,

  17. ea: dataTpmArgs,

  18. ...

  19. };

  20. sendToNginx(data);

  21. }

  22. ...

  23. }, false);


  24. /**

  25. * 获取 dom 属性值

  26. * @param {HTMLElement} el

  27. * @param {String} attr

  28. */

  29. function getNodeAttr(el, attr) {

  30. return (el && el.getAttribute && el.getAttribute(attr)) || "";

  31. }


  32. function sendToNginx(info) {

  33. let str = "";

  34. for (let i in info) { // Object.keys(obj) ie 9 以上才兼容, for..in.. ie 6 以上兼容

  35. if (str === "") {

  36. str = i + "=" + info[i];

  37. } else {

  38. str += "&" + i + "=" + info[i];

  39. }

  40. }

  41. const url = 'https://tpm.tuyacn.com/tpm.gif' + '?' + str;

  42. new Image().src = url

  43. }

这样,我们实现了简单的元素 click 事件埋点上报,相应的可以监听的事件还可以有页面的 DOMContentLoaded、 beforeunload、 visibilitychange 事件等。如下图所示,具体可以参考 网页的生命周期

生命周期

还可以监听元素的 mouseover、mouseout 事件,只是这里不做埋点逻辑处理。

代码埋点的第二种方式,通过 window.track 方法触发埋点上报

当存在以下业务场景时:

上述业务场景不能通过 dom 代码埋点方式获取,js 为此提供了 track 方法,并挂在 window 上,供开发者调用。

  1. // 开发者添加一个 track 事件

  2. window.track('UA', 'vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0', '{"pid":1, "uid":2}');


  3. /**

  4. * js 将 track 事件解析上报到 nginx

  5. */

  6. function track(type, code, others) {

  7. const data = {

  8. type: 'UA',

  9. ec: code,

  10. ea: others,

  11. ...

  12. };

  13. sendToNginx(data);

  14. }

2. 可视化圈选埋点

可视化圈选埋点,通俗的讲就是无需开发者在代码中加入埋点逻辑代码,只需要通过 UI 点点点的方式就能埋好一个点,有效避免了埋点代码污染问题。被点击到的 dom 元素都赋予唯一标识,这里采用 dom 元素唯一的 xpath 当作唯一标识。说白了,可视化圈选埋点就是制定一套规则,云端利用这套规则去海量的数据里清洗出需要的数据,而规则中就包含了 xpath。

这里我们以官网为例介绍一下如何进行可视化圈选埋点:

此时默认官网已经接了埋点 js ,用户在平台(俗称:天眼)的地址栏中,输入官网地址 https://www.tuya.com/cn/ ,点击圈选按钮,就可以开始可视化圈选埋点了。我们提供了以下 3 种业务圈选场景:

使用方式如下图所示:

那么,我们是如何实现的呢?

首先官网页面以 iframe 的形式嵌入在天眼中,然后在官网页面设置响应头 X-Frame-Options: *.tuya-inc.com ,允许被 *.tuya-inc.com 域名下的系统嵌套。再建立父子页面间的通信可以啦,这里通信方式有几种呢?常用的通信方式有 mqtt 通信、iframe 通信、http 请求通信,http 属于单向通信不可以使用。

使用 iframe 通信的优点有:

缺点有:

如果使用 mqtt 通信,那么会是 父页面 <-> mqtt 网关 <-> 子页面页面 这样的链路,

优点:

缺点:

综合考虑下来,目前我们采用的是 iframe 通信方式进行可视化埋点,那么我们就开始正式进行数据通信啦 ⚡️

在埋点 js 中添加以下代码:

  1. ...

  2. // 子页面接收来自父页面的消息

  3. function tpmReceive() {

  4. window.addEventListener("message", (event) => {

  5. const data = event.data;

  6. }, false);

  7. }


  8. /**

  9. * js 向父页面发送消息

  10. * @param {String} status: "circle" | "browser" // circle 为圈选模式,browser 为浏览模式

  11. */

  12. function tpmSendToTianyan(data) {

  13. if (window.parent && (status === "circle")) {

  14. window.parent.postMessage(data, "*");

  15. }

  16. }

  17. ...

在圈选平台中添加以下代码:

  1. ...

  2. // 父页面接收消息

  3. useEffect(() => {

  4. window.addEventListener('message', tianyanReceive, false)

  5. return () => {

  6. window.removeEventListener('message', tianyanReceive, false)

  7. }

  8. }, [url]) // https://www.tuya.com/cn/


  9. // 向子页面 iframe 发送消息

  10. function tianyanSendToTpm(data) {

  11. iframe.current.contentWindow.postMessage(data, '*')

  12. }

  13. ...

当子页面在接收到 圈选 的消息时,在 dom 中插入一段 style 标签,添加圈选选中的样式,

  1. // 嵌入 style 标签

  2. function insertStyle() {

  3. if (document.getElementById("tianyan")) {

  4. return;

  5. }

  6. const d = document.createElement("style");

  7. d.setAttribute("type", "text/css");

  8. d.setAttribute("id", "tianyan-circle");

  9. d.innerHTML = `

  10. .tpm-circled-style {

  11. outline: 1px solid red !important;

  12. outline-offset: -1px !important;

  13. }

  14. .tpm-circled-style-dashed {

  15. outline: 1px dashed red !important;

  16. outline-offset: -1px !important;

  17. }`;

  18. document.getElementsByTagName("head")[0].appendChild(d);

  19. }


  20. // 删除 style 标签

  21. function removeStyle() {

  22. const el = document.getElementById("tianyan-circle");

  23. if (!el) {

  24. return;

  25. }

  26. el.parentNode.removeChild(el);

  27. }

当子页面 dom 在接收到 点击 的消息时,获取当前 dom 的 xpath 发送给父页面。xpath 从控制台也可以拿得到,右键点击元素 -> 复制 -> "xpath",

复制出来就是这样子的一个字符串,

  1. // xpath

  2. "/html/body/div[1]/div/div[2]/div[6]/div[2]/div[3]/div/div/div[1]/div/div/div/div[2]/a/div[1]/img"

然后我们在代码中以向上遍历节点拿到 xpath ,

  1. // 获取 dom 的 xpath

  2. function getXPath(element) {

  3. let xpath = "";

  4. for (

  5. let me = element, k = 0;

  6. me && me.nodeType == 1;

  7. element = element.parentNode, me = element, k += 1

  8. ) {

  9. let i = 0;

  10. while ((me = me.previousElementSibling)) {

  11. if (me.tagName == element.tagName) {

  12. i += 1;

  13. }

  14. }

  15. const elementTag = stringToLowerCase(element);

  16. let id = i + 1;

  17. id > 1 || k === 0 ? (id = "[" + id + "]") : (id = "");

  18. xpath = "/" + elementTag + id + xpath;

  19. }

  20. return xpath;

  21. }

那么拿到 xpath 之后,需要再映射回去的话,采用 document.evaluate() 方法。这个方法 ie 不支持的,但是有谁规定我们要使用 ie 在平台上进行圈选呢,毕竟 ie 的兼容性是我们所厌恶的。

另外,这是拿到当前元素 xpath,如果是圈选同级元素或是圈选所有子元素,那就再递归遍历一下就好了。后期再拿这些 xpath 去清洗、过滤、分析就能拿到我们想要的数据了。

3. 无痕埋点

无痕埋点也叫 “全埋点”,有了以上两种方式埋点,无痕埋点自然也就简单了,点击到任何 dom 时都进行上报,然后再获取 dom 的 xpath 作为唯一标识,就可以轻松实现全埋点上报了,剩下的就交给数仓获取、清洗数据吧。。。

  1. document.body.addEventListener('click', function(e) {

  2. ...

  3. // 第二步:获取目标元素(这里可以过滤掉点击到 body 的脏数据)

  4. const el = e.target;


  5. // 第三步:获取属性值和 xpath

  6. const dataTpm = getNodeAttr(el, 'data-tpm');

  7. const dataTpmArgs = getNodeAttr(el, 'data-tpm-args');


  8. const xpath = getXPath(el);


  9. // 第四步:将获取到的数据上报到 nginx,产生一条日志

  10. const data = {

  11. type: 'click',

  12. ec: dataTpm,

  13. ea: dataTpmArgs,

  14. xpath,

  15. ...

  16. };

  17. sendToNginx(data);

  18. ...

  19. }, false);

埋点上报到 nginx

这里我们在本地搭一个 nginx 来模拟一下,先做一些准备工作:

做完这些,然后我们在本机浏览器访问 nginx 容器内的 tpm.gif 文件,带上一些 querystring 传递埋点信息,如下所示,

  1. // 浏览器访问,或者 curl 一下

  2. http://localhost:8080/tpm.gif?ss=1440x900&ws=709x775&sp=0x0&ac=Mozilla&an=Netscape&pf=MacIntel&lg=zh-CN&tz=-8&dpr=2&appid=portal-zh&csp=&gid=TY-58aaf9dfb80134ff&uid=guest&sver=3.3.12&aver=1.0.0&now=1606221479537&flt=1606221472429,1&src=&url=https://www.tuya.com/cn/&ref=&lang=&uuid=TY-58aaf9dfb80134ff-1606221479537&previous_uuid=TY-58aaf9dfb80134ff-1606221478012&previous_event=&seq_id=seq_id_eaf66e2bc936279a&sub_app_id=&type=pageClick&ea=&ec=&eh=&ep=485x37&xp=/html/body/div/div/div/div/div/div/div/div[1]&ct={%22tagName%22:%22div%22}&image=&text=

再去 nginx 上看下日志,nginx 日志默认打印在 access.log 文件中,文件中显示有一条访问 tpm.gif 的日志,如下图所示,

nginx图片1


以上就是完整的一条埋点数据上报到 nginx ,然后云端需要做的是进行数据采集、分析和其它一系列操作,下面会具体介绍到如何采集数据。

埋点数据是如何收集的?

数据采集分为 实时数据 和 离线数据。

如下图所示,展示的是 实时数据 采集的数据流向的两种方式,主要经历的过程是:

第一种方式:目前应用场景主要是客户端实时校验。nginx 日志通过 filebeat 收集统一上报到 日志kafka,日志kafka 会接收到来自很多其它应用的日志数据,所以通过 flink 过滤出哪些数据是需要的数据(埋点数据)再上报到 数据kafka ,然后 Java应用 去消费这些数据,通过 websocket 把这些实时数据给 web 端。

第二种方式:主要是提供各种应用容器日志的数据查询,也就是 ELK 模型。nginx 日志通过 logstash 收集,logstash 和 filebeat 同样都可以做数据收集,它们的区别主要是,filebeat 是一个轻量型日志采集器,主要的能力是数据 收集;而 logstash 更多的能力是体现在数据的过滤和转换上。logstash 收集到数据后,将数据统一往 ES 中存,然后在 kibana 中建一个索引就可以看数据啦。

数据流模型

多区部署

通过前面的操作,就完成了一条完整的埋点数据从收集到查看。

但实际场景中我们可能会有多区部署的情况,并且在数据量很大的情况下需要多个 nginx 来做一层应用层面的负载均衡,然后又要在中国区看所有的所有区站点的数据。如下图所示,我们按之前的操作在各个区都部署一套,将埋点 js 文件每个区都发一遍,然后将收集到的数据统一汇聚到中国区计算。一般情况下看某一天所有区的数据,会有时差存在,一般离线数据以 T+1 的形式展现,这里再需要额外处理一下。

多区部署

我们开开心心把上面流程都搞通了,一切大功告成,来杯 ☕️。

压测

流程都通了,我们还得看下并发量很高的情况下会不会丢数据。

我们在本机安装 key 压测工具,来模拟一下压测本机运行的 nginx 容器。安装好 hey 工具以后,在终端输入以下命令,一共发起 30000 个请求,并发数量为 3000 个,

  1. # -c 要同时运行的 worker 数量

  2. # -q 速率限制,每个 worker 的 QPS

  3. # -n 请求数量

  4. hey -c 50 -q 3000 -n 30000 -m GET http://localhost:8080/tpm.gif\?ss\=1440x900\&ws\=709x775\&sp\=0x0\&ac\=Mozilla\&an\=Netscape\&pf\=MacIntel\&lg\=zh-CN\&tz\=-8\&dpr\=2\&appid\=portal-zh\&csp\=\&gid\=TY-58aaf9dfb80134ff\&uid\=guest\&sver\=3.3.12\&aver\=1.0.0\&now\=1606221479537\&flt\=1606221472429,1\&src\=\&url\=https://www.tuya.com/cn/\&ref\=\&lang\=\&uuid\=TY-58aaf9dfb80134ff-1606221479537\&previous_uuid\=TY-58aaf9dfb80134ff-1606221478012\&previous_event\=\&seq_id\=seq_id_eaf66e2bc936279a\&sub_app_id\=\&type\=pageClick\&ea\=\&ec\=\&eh\=\&ep\=485x37\&xp\=/html/body/div/div/div/div/div/div/div/div\[1\]\&ct\=\{%22tagName%22:%22div%22\}\&image\=\&text\=

结果如下图所示,

hey压测

30000 个请求状态都显示 200,实际的 QPS 为 1007.0157。

再进到 nginx 容器看下日志,追加的数量也为 30000 条,刚才不是有 2 条么,

nginx图片2

好了,你可以喝 ☕️ 去了 。

参考链接

现有成熟的产品:

关于本文 作者:@聂风 原文:https://zhuanlan.zhihu.com/p/313016178


为你推荐


【第2026期】「可视化搭建系统」——从设计到架构,探索前端领域技术和业务价值


【第2075期】多端研发体系:可渐进迁移的提效之路


欢迎自荐投稿,前端早读课等你来