无论金融、电商、互娱等行业,还是支付、活动、收货等场景都需要用户行为埋点来分析转化率,进而指导业务流程优化、用户体验提升、交易量提高。行为埋点会影响到业务发展中的关键决策,显得十分重要。但互联网行业的特点是快速、灵活,客观上要高效响应用户需求变化,这就让传统的硬编码埋点暴露了“点位多、验证难、代码冗余、难清理、分析周期长、交付流程长”等痛点。
一个页面有几十个点位,每次需求迭代可能只会改到其中的几个。在改动时首先要修改测试数据来复现业务场景,然后从众多点位中找出2~3点进行修改,这在体验上有点难为测试和研发,带来的成本较高。之后研发、测试会重点关注埋点的发送时机和内容准确度,最终的埋点结果要等N周后的转化报告来验证,因此在当时测试了也不一定能保证100%的埋点准确性。PM和BA最终在分析埋点转化时发现异常,又要走一遍研发流程来校正埋点,整体表现比较低效。有时为了一个埋点写的代码比实现业务功能写的代码还多,变相增加了研发维护项目工程的复杂度。随着需求的迭代,埋点内容越来越多,PM、研发一般都只关注新的业务需求,并为其增加新的埋点,历史埋点一般不会清理,因为时间太长也不敢清理,最终也会造成用户行为分析系统的高负载和查询效率的下降。
为了解决以上问题,可视化全自助无痕埋点平台通过无代码、全自助、可视化的方式来完成用户行为埋点的配置、发布、验证、管理、分析,让BA、PM、RD、QA都能轻松愉悦的完成埋点需求的交付。
该篇文章全文较长,重点讲述可视化全自助无痕埋点平台的技术方案和难点。通读该篇文章您将收获以下内容:
文章内容较为技术,略显晦涩。如果您只关心平台如何使用,对于四、五章节讲述的方案、难题可以滤过,后续我们也将提供专门的用户手册来指导使用。接入方法也只要引入一个JSSDK,增加一个Webpack配置即可使用可视化埋点的功能。
现在市面上现有的全埋点方案都是全量获取页面元素属性,在场景极简、分析策略极简的情况下还可以适用,但也需要维护大量页面元素和业务数据的绑定关系,而且95%以上的上报数据都是无用的,白白增加后端分析系统的负担。全埋点方案最大的问题是无法直接关联业务接口数据、计算过程数据、业务状态数据以满足用户对行为分析的数据需求。因此在骨感的现实面前,全埋点方案显得华而不实。
PM在使用时,需详细了解每个页面的布局和Dom元素,这样埋点才能关联具体页面,对PM而言不友好,学习成本太高。
在平台进行埋点时,需要配合后端进行数据改动才能完成具体的场景埋点。不支持场景还原和直接接口数据改动,这成为了一座大山。
一般埋点都是需要点位属性、接口数据、页面状态数据组合在一起来完成上报,这可以降低用户行为的分析难度,全埋点方案无法满足该诉求。
可视化埋点,重在可视化、全自助,即时配置、即时发布、即时验证、即时管理。
只要在可视化埋点平台完成 页面加载、选择类型、关联数据、发布配置 四步即可完成数据自动上报。
PM、研发可以直接在平台中可视化的设计埋点,然后自动产生点位信息管理页面, 不需要再维护独立的埋点文档 。
通过为每个Dom元素打上独特的TrackerId,实现具体页面/按钮元素等的获取。
曝光通过MutationObserver来监听元素变动,点击事件通过addEventListener lick事件方式来Hook,接口数据返回Action通过defineProperty来捕获。
埋点配置是贯穿整个流程的,平台需要产生配置,页面需要读取配置。
通过以上四部曲即可完成每个业务流程的可视化埋点。
整个方案的目的就是为了让业务流程读取埋点配置文件,并自动完成埋点数据上报。
通过硬编码的方式为每个标签添加TrackerId,简单粗暴,但这对编码者非常不友好,不可取。
最初我们通过自定义Loader来实现TrackerId生成。首先通过Loader拿到Vue源码,然后通过三方工具htmlparser2对源文件进行解码,之后拿到解析后的语法树,动态的在语法树结构上把TrackerId属性添加上,最后再通过getOuterHTML编码回去递交给下一个Loader。概括起来就是如下三步:
但这个方案里面存在解码和编码后大小写不一致的问题,在第三步编码后导致下一个Loader解析失败。为了解决这个问题需要投入额外的研发成本,于是我们换了一个更加简单直接的方法Hook Vue-Loader。
众所周知在Webpack生态下是使用Vue-Loader进行Vue代码进行解析的。日常开发用的VUE项目可以在Package.json中看到Vue-Template-Compiler这个Dependencies,多数场景下这个依赖是与Vue-Loader一起使用的,只有在编写具有非常特定需求的构建工具时才需要单独使用它。
那么这个Vue-Template-Compiler到底干什么的呢?查阅官方文档可知:为VUE提供预编译能力。VUE的模版编译会在打包时完成,以避免运行时编译开销和CSP限制,加快应用运行。
内容安全策略 (CSP) 是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本 (XSS) 和数据注入攻击等。无论是数据盗取、网站内容污染还是散发恶意软件,这些攻击都是主要的手段。
在Webpack中Loader的作用就是对模块的源代码进行转换。基于以上对Vue-Template-Compiler的了解,查阅相关文档可发现在Vue-Loader中可以配置ComplilerOptions。这个配置选项正是Vue-Template-Compiler所提供的功能。
继续深究会发现,Compiler为我们提供一个编译器模块的数组,用于配置编译器模块的细节。
同时也为我们提供了相应的Flow声明
declare type ModuleOptions = {
// transform an AST node before any attributes are processed
// returning an ASTElement from pre/transforms replaces the element
preTransformNode: (el: ASTElement) => ?ASTElement;
// transform an AST node after built-ins like v-if, v-for are processed
transformNode: (el: ASTElement) => ?ASTElement;
// transform an AST node after its children have been processed
// cannot return replacement in postTransform because tree is already finalized
postTransformNode: (el: ASTElement) => void;
genData: (el: ASTElement) => string; // generate extra data string for an element
transformCode?: (el: ASTElement, code: string) => string; // further transform generated code for an element
staticKeys?: Array<string>; // AST properties to be considered static
};
这时我们就可以预定义一个ModuleOptions,在返回的AST中,完成我们的TrackerId添加,同时也保证了兼容性。
相关的配置可参考如下,在Webpack中添加Vue-Loader配置的同时加入Options选项即可。
.options({
compilerOptions: {
preserveWhitespace: false,
modules: [addTrackerId(‘mktop’)]
}
});
这时我们已经解析了.Vue文件的语法树,下面我们通过多维属性为标签添加TrackerId。
const trackName = 'trackId';
const addTrackerId = id => {
const addTrackerId = AST => {
const { attrsList, attrsMap, parent, tag } = AST;
if (attrsList[0]) {
let name = trackName;
if (attrsMap['v-for'] || attrsMap[':key'] || attrsMap['key']) {
attrsMap.class = (attrsMap.class || '') + ' list_Class_track';
attrsMap['parentAttrName'] = 'loop_sign';
}
let parentAttrName = parent
? (attrsMap && parent.attrsMap['parentAttrName']) || ''
: '';
if (attrsMap['parentAttrName']) {
parentAttrName = attrsMap['parentAttrName'];
}
let trackId = `${id}_${tag}_${attrsMap.class || ''}_${parentAttrName}`;
if (attrsMap['unique']) {
trackId += `_${attrsMap['unique']}`;
}
if (attrsMap['key'] || attrsMap[':key'] || attrsMap[':index'] || attrsMap['index']) {
if (attrsMap['key'] || attrsMap[':key']) {
trackId += '_${' + attrsMap[':key'] + '}';
}
if (attrsMap[':index'] || attrsMap['index']) {
trackId += '_index@${' + attrsMap[':index'] + '}';
}
trackId = `\`${trackId}\``;
name = `:${trackName}`;
}
attrsMap[name] = trackId;
attrsList.push({
name,
value: trackId,
start: attrsList[0].start,
end: attrsList[0].end
});
}
return AST;
};
return {
preTransformNode
};
};
要生成基于当前Dom元素的唯一id,需要涉及到Dom元素的多种属性。可由以下 5 步骤完成唯一id的生成。
由上述五步可以保证每次打包节点的 trakcerId 都不会发生改变,这样没有新需求功能,就不用重复修改埋点配置。
具体 Vue 的 AST 语法可以参考 http://caibaojian.com/vue-design/appendix/ast.html#attrslist
至于用到的参数有如下几个【 AttrsList, ttrsMap, Parent, Tag】,之后就可以把生成的TrackerId Append到新的AttrsList。
ast = {
type: 1,
attrsList: [
{
name: 'v-for',
value: 'obj of list'
},
{
name: 'class',
value: 'box'
}
],
attrsMap: {
'v-for': 'obj of list',
'class': 'box'
},
tag: 'div'
}
在事件的捕获上,我们的原则是尽量使用原生API,以保证对更多框架和第三方库的兼容性。
曝光事件比较特殊,做到监听容易,但做到高边缘容错率就是非常困难了。比如,Hash路由变化,动画元素的变化,可视化修改样式的时候尽量减少运算量,快速上报埋点等。
曝光过程其实就是元素从无到有的过程,这里就是涉及到了Dom的变化,所以我们首先要做到监听Dom变化来执行上报方法。关于Dom变化的监听市面上大概有一下做法:
前三种都有较大缺陷,最终我们选用MutationObserver来进行Dom监听。
Mutation Observer API 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。Mutation Observer 则是异步触发,DOM 的变动并不会马上触发,而是要等到当前所有 DOM 操作都结束才触发。这样设计是为了应付 DOM变动频繁的特点。
可以发现这个API非常适合我们做曝光事件的捕获,兼容性也比较完善。
addDocumentListener() {
if ('MutationObserver' in window) {
let mutationObserver = new MutationObserver(mutations => {
if (mutations) {
mutations = mutations.filter((mutationRecord) => mutationRecord.target.nodeName !== 'BODY');
if (mutations.some((mutationRecord) => this.animationEffectDom.includes(mutationRecord.target.className))) return;
if (mutations.length) {
setTimeout(() => {
this.init(mutations);
}, 0);
}
}
});
mutationObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
}
}
对于点击事件捕获,我们采用的是addEventListener监听Clickevent的方式,以实现捕获具体的每个元素标签的方案。
如上图所示,点击事件会由底层往上依次冒泡,因此我们可以在Document层捕获到所有的点击事件,然后基于当前元素Target的TrackId来获取具体的埋点配置信息。
clickPoint() {
document.body.addEventListener('click', e => {
try {
this._nowTime = Number(new Date());
if (this._nowTime - this._lastTime > this.gapTime || !this._lastTime) {
this.record_click_current_dom_info = e;
this.preventDoubllePoint = {};
this.clickEventHandle(e);
this._lastTime = this._nowTime;
}
} catch (e) {
}
}, true);
}
下面是对点击的点位进行数据对比,并进行最终埋点。
async clickEventHandle(e) {
let trackId = e.target.getAttribute(UNIQUE_SIGN) || '';
if (this.json.container[trackId]) {
trackId = this.json.container[trackId];
}
let res = this.clickArr.some(e => trackId === e || trackId.includes(e));
if (res) {
let id = trackId;
let config = this.json.click[id];
let params = await this.getParams(config, e, 'click');
POINT('h5_clk', {
tgt_event_id: 'super_click',
tgt_name: config.tgt_name,
...params,
trackId: id,
...this.params
});
}
}
数据关联是最终上报埋点的纽带,埋点数据是多维数据的组合。这里我们会将以下三种数据进行关联以满足业务分析的诉求。
静态数据可以理解为埋点配置预设的值,这类数据直接从埋点配置文件读取即可。
接口数据,这是一种比较常见的埋点信息,需要关联到接口中具体的字段进行上报。本身接口响应就属于一种异步Action,当我们元素曝光或者点击触发时若接口数据还未触达,那么这里就涉及到如下两个问题:
我们采用的方式是修改现有的网络请求JSSDK,通过Axios Interceptors的方式Hook住所有接口响应,然后挂载到Window中。
saveApiDataResponse(url, data) {
let dataset = window.dataset;
if (!dataset) {
dataset = {};
dataset[`${url}`] = data;
} else {
dataset[`${url}`] = data;
}
},
这种方式可以拿到接口数据,但是对于点击触发及曝光触发的接口,我们这种方式显然并不完善,因此我们要对接口数据进行异步关联。
对于异步数据的关联,我们采用的方案是用defineProperty通过Proxy方式实现数据的即时同步。说到defineProperty大家熟知的Vue2.X的双向绑定就是通过此方法来完成的。
Object.defineProperty(window, 'dataset', {
set: (x) => {
value = x;
},
get: (x) => {
this.getDataset = (resolve) => {
if (!this.controllerSwitchCallback) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
resolve && resolve(value);
}, 0);
};
return value;
}
});
getApiData(apiPath) {
this.controllerSwitchCallback = true;
return new Promise((resolve) => {
let apiResult = window[apiPath] || window.dataset[apiPath];
let timer = null;
if (!apiPath) {
return resolve({});
}
if (!apiResult || apiResult === -2) {
this.getDataset(
(dataset) => {
const data = dataset[apiPath];
if (data && apiResult !== -2) {
clearTimeout(timer);
this.controllerSwitchCallback = false;
resolve(data);
}
});
timer = setTimeout(() => {
resolve({});
}, 15000);
}
if (apiResult) resolve(apiResult);
});
}
对于多维数据组合,相对于静态数据和异步数据关联其难度大大增加。可能是一个接口的多个字段的组合,也可能是多个接口的多个字段在进行计算后的结果。这里我们API中开放了注册方法,研发在写业务代码过程中遇到计算结果时调用下注册方法即可。
为了复杂的埋点场景提供了以下三个API,可以Cover 100% 复杂埋点问题
将registerParam和handleSetParam Api联合起来使用可以实现多接口多字段组合埋点的场景,注册参数也会被可视化平台自动拾取。
通过registerParam注册好我们的参数,等待接口响应或者计算完成后通过handleSetParam进行配置参数设置。
registerParam(params) {
if (params instanceof Array) {
params.forEach((_param) => {
_param.value = _param.value || -2;
record[_param.key] = _param.value;
top.postMessage({ 'handleParamValue': _param }, '*');
});
} else {
throw new Error('registerParam 的值为数组');
}
}
handleSetParam(_param) {
if (_param) {
record[_param.key] = _param.value;
if (top) {
top.postMessage({ 'handleParamValue': _param }, '*');
}
}
}
handlePoint 此API为开发者提供了额外的手动埋点功能,作为兜底策略,真的有非常特定的场景时可通过此API完成埋点。
通过以上三种方式已经完成了埋点数据的获取,但最后我们还要将埋点配置与业务页面中的具体数据进行匹配,比如将配置文件中的某一项匹配为某个接口中的某一个字段。这个匹配过程也是一个错综复杂的地方。
在架构设计中我们参考了VUE模板编译的过程,通过平台设置了埋点规则,然后在捕获用户交互事件的过程中进行正则匹配,替换为真实的埋点信息。得到真实的埋点信息后,JSSDK会根据埋点时机自动上报。
async getParams(config, domEvent, eventType) {
let paramsData = JSON.parse(JSON.stringify(config));
let recordArrayListApi = null;
let arrayIndex;
if (paramsData.params) {
for (const i in paramsData.params) {
const cur_page = paramsData.params[i];
if (!cur_page.path.includes('${') && !cur_page.path.includes('.')) {
paramsData.params[i] = cur_page.path;
continue;
}
let matchValue = cur_page.path.match(EXP_MATCH_$)[0];
let cur_apiData = await this.getApiData(cur_page.apiPath, matchValue);
let cur_param = null;
if (cur_apiData instanceof Array) {
cur_param = cur_apiData;
} else {
cur_param = JSON.parse(JSON.stringify(cur_apiData));
}
let variableApiPath = null;
if (i === 'arrayApi') {
recordArrayListApi = cur_page.path;
arrayIndex = this.searchDomIndex;
continue;
}
if (cur_page.path.includes('${') && cur_page.path.includes('}')) {
let variableApiResult = null;
if (cur_apiData instanceof Array) {
variableApiResult = cur_apiData;
} else {
variableApiResult = JSON.parse(JSON.stringify(cur_apiData));
}
if (recordArrayListApi && matchValue.includes(recordArrayListApi)) {
if (matchValue.includes('[')) {
matchValue = matchValue.replace(EXP_REPLACE_ARRAY, arrayIndex);
} else {
matchValue = matchValue.replace(
recordArrayListApi,
`${recordArrayListApi}[${arrayIndex}]`
);
}
}
if (matchValue === 'target_value' && domEvent) {
const dom = eventType === 'click' ? domEvent.target : domEvent;
if (dom) {
variableApiResult = dom.innerHTML.replace(CLEAR_SPACE, '');
}
.......
cur_param = cur_page.path.replace(EXP_REPLACE_$, variableApiResult);
} else {
cur_page.path.split('.').forEach(_v => {
if (_v.includes('[')) {
let _vArr = _v.split('[');
_vArr[1] = _vArr[1].replace(']', '');
for (const n of _vArr) {
if (cur_param && cur_param[n]) {
cur_param = cur_param[n];
}
}
} else {
if (cur_param && cur_param[_v]) {
cur_param = cur_param[_v];
}
}
});
}
paramsData.params[i] = cur_param;
}
}
在上文中已经实现了读取埋点配置来自动上报数据的功能,那么可视化平台要做的就是产生埋点配置文件,方便业务流程页面获取。
以上两个都是可视化埋点的必备功能。
在可视化平台中场景还原担任者不可或缺的作用,通过场景还原完成不同业务状态的埋点。在场景还原中,提供了两种方案进行场景的切换。
Mock数据是研发过程中添加的数据,是将研发原来在Charles工具中Mock数据的全过程保存了下来,即为可视化平台提供了场景还原功能,又可为后续开发新功能提供参考,最后也降低了修改测试环境数据的成本(Mock数据不会发布到线上)。读取页面的时候平台会自动加载当前SPA的所有业务场景,通过点击按钮在不同场景进行切换,快速完成埋点。
setSceneData(_data, url) {
MOCK_DATA[url] = _data.data
this.dialogVisible = false
this.initIframePage()
this.$refs.reloadIframe.reloadIframe()
}
可以从以下配置中看到被反复使用的业务场景。
当在可视化埋点平台点击场景按钮时,就会同步状态数据,显示对应场景。
在可视化平台中还提供了对接口数据的编辑功能,当我们要动态修改接口数据时,可视化平台会发送Push消息到页面来完成场景再现。同时网络库会动态的更新存在的Mock数据,详细可以通过以下代码进行了解。
可视化平台动态修改接口数据
submitMock() {
MOCK_DATA[this.mockTitle] = JSON.parse(this.mockText)
this.$refs.reloadIframe.reloadIframe()
},
响应数据变动
window.addEventListener('message', (event) => {
stopPropagation = false;
if (event.data instanceof Object && event.data.MOCK_DATA) {
localStorage.setItem('MOCK_DATA', JSON.stringify(event.data.MOCK_DATA || {}));
});
}
对于页面的交互,可想而知我们需要业务流程页面和可视化埋点平台页面进行联动,这就涉及到数据通信及同步问题。
在页面加载上,采用IFrame的方式进行加载,这样可以将业务流程页面和可视化埋点平台页面的运行环境完全隔离。在Cookie跨域登录态共享的问题上采用的同域的方式进行页面加载,即可视化埋点平台有ppdai.com、ppdaicorp.com、xinye.com、koofenqi.com等多个域名,这些域名都只能在内网访问。最后数据通信采用PostMessage的方式完成,然后根据业务流程页面的交互行为来生成埋点配置。
对于用户操作埋点时Dom元素如何高亮显示,也是通过addEventListener的方式Hook住点击事件,然后再动态的Append样式到Classlist中来实现的。但埋点状态分为交互模式和埋点模式两种。开启交互模式后,业务流程可以正常的进行跳转。开启埋点模式后会自动展示出当前页面的所有点位,并可以选择元素完成埋点。
insertHookClick() {
window.addEventListener('click', (v) => {
if (stopPropagation) {
return;
}
const elememtId = v.target.getAttribute(trackName);
if (!elememtId) {
return;
}
const result = this.hasLoop(v);
const elementsMap = this.searchChildrenElement(v, elememtId);
v.preventDefault();
v.stopPropagation();
v.cancleBubble = true;
v.returnValue = false;
// this.insertPointElementEffect();
if (top) {
top.postMessage({
element: v.target.getAttribute(trackName),
loop_sign: !!result,
elementsMap
}, '*');
top.postMessage((window.dataset || ''), '*');
this.selectedEffect(v);
}
}, true);
}
selectedEffect(v) {
if (recordTargetElement) {
recordTargetElement.classList.remove('addClickIframeBackground');
}
recordTargetElement = v.target;
v.target.classList.add('addClickIframeBackground');
}
信也科技集团研发中心借款前端研发团队CC、xiaocong、yuhui