多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

前端数据埋点(2):无埋点——重写原生 API 之后,业务代码不用自己打点

前端数据埋点(2):无埋点——重写原生 API 之后,业务代码不用自己打点 上一篇一次点击如何变成一条埋点系列前端数据埋点· 第 2 篇先说结论对比每个业务点手写监听无埋点采集写在哪按钮、接口、路由各写一遍init时改原型 / 绑一次全局监听和上报的关系采集和fetch(/log)写在一起重写只负责截获处置进栈还是上报在订阅回调里同一类事件容易绑两次、漏一次每类事件有开关只包装一次自己的上报请求容易被再次采集打成死循环POST 且 URL 命中dsn/trackDsn则跳过无埋点不是「不用埋点」。它是业务照常xhr.send()、history.pushState()、点按钮SDK 在原生实现外包一层把技术事件截下来。业务语义「这是结算按钮」仍然要靠主动埋点下一篇之后会写。init之后采集层实际做了三件事给每类事件登记一个回调 → 按类型重写对应原生 API → 原生调用发生时触发回调。回调里才决定进不进行为栈、要不要send。1. 缺口监听写在业务里一定漏第 1 篇说过漏、脏、没法复现。无埋点专门挡住前两个里的「漏」和「脏」。脚本报错不会预先告诉你它在哪一行爆发接口失败发生在axios/fetch内部SPA 跳转走的是history.pushState不是一次真正的页面刷新。这些都不是你在 Vue 组件里onClick能覆盖完的。所以采集点必须下沉到所有业务共享的那一层浏览器原生 API。改的是XMLHttpRequest.prototype.send、window.fetch、history.pushState以及window/document上的事件。业务代码一行都不用加这就是「无」的含义——无业务侧采集代码不是无数据。相对主动埋点无埋点拿到的是「一次 XHR、一次 click、一次脚本错误」它不知道 click 的产品名是pay_click。两条线不要互相替代。2. 概念词典词是什么相对什么本篇怎么用重写把对象上的函数换成「先记一笔再调用原函数」的包装相对addEventListener那是额外听一份原函数不动XHR / fetch / history / console 用重写error / click 用监听订阅按事件类型登记回调重写侧只trigger(type, data)相对「在send包装函数里直接上报」采集和处置拆开换处置逻辑不必再改 XHR开关 / flag每类事件一个布尔值已静默或已包装相对反复init导致send被包两层silentXhr: true则根本不重写 XHR捕获阶段事件从window往目标走的那一段发生在冒泡之前相对默认的冒泡阶段资源加载失败不冒泡必须在捕获阶段听errorclick 也在捕获阶段听以免被stopPropagation吃掉上报 URL 跳过识别这次请求是不是 SDK 自己的 POST相对过滤名单靠业务自己记dsn、trackDsn出现在 URL 里则不触发采集3. 接入顺序先打开关再订阅再重写init时顺序不能反。先根据配置把「这类事件不要采」写成 flag再去登记回调登记成功才真正改原生 API。functionsubscribe(type,callback){if(getFlag(type))returnfalse;// 已静默或已经包装过setFlag(type,true);handlers[type]handlers[type]||[];handlers[type].push(callback);returntrue;}functionaddReplaceHandler({type,callback}){if(!subscribe(type,callback))return;replace(type);// 只有订阅成功才重写}silentXhr: true会在订阅之前把 XHR 的 flag 设上于是subscribe直接失败XMLHttpRequest保持原样。没有静默时第一次订阅成功flag 立刻变成 true——同类事件不会被包第二层。send被包两次的后果是一次业务请求触发两次采集行为栈和上报都会翻倍。登记好的回调由重写侧触发functiontrigger(type,data){if(!handlers[type])return;handlers[type].forEach((fn){try{fn(data);}catch(e){// 回调自己抛错不能再砸穿业务请求}});}回调必须自己 try/catch。包装函数跑在业务的xhr.send调用栈里采集逻辑一抛错支付请求也会失败。常见类型和对应手段type手段截获什么xhr重写open/send接口耗时、status、请求体fetch重写window.fetch同上Promise 风格errorwindow捕获阶段听error脚本错误、图片 / 脚本加载失败unhandledrejection听unhandledrejection没 catch 的 Promisedomdocument捕获阶段听click点击目标的 HTML 摘要history重写pushState/replaceState并包一层onpopstateSPA 路由 from → tohashchange听hashchange没有onpopstate时hash 路由console重写console.*调试轨迹默认只进行为栈下面按「最容易写错」的几条展开不把每个 type 再讲一遍处置那是整形和行为栈的篇。4. 重写 XHRopen 记账send 收尾POST 到 DSN 则跳过一次 XHR 的生命周期跨两个函数open(method, url)时还没有 statussend(body)结束才有。包装必须把中间态挂在这个 xhr 实例上不能用一个全局变量——页面上会同时飞很多请求。replaceOld(XMLHttpRequest.prototype,open,(originalOpen){returnfunction(method,url,...rest){this.__record{method:String(method).toUpperCase(),url,sTime:Date.now(),type:xhr,};returnoriginalOpen.call(this,method,url,...rest);};});replaceOld(XMLHttpRequest.prototype,send,(originalSend){returnfunction(body){const{method,url}this.__record;this.addEventListener(loadend,(){if((methodPOSTisSdkUrl(url))||isFiltered(url))return;this.__record.reqDatabody;this.__record.statusthis.status;this.__record.elapsedTimeDate.now()-this.__record.sTime;if([,json,text].includes(this.responseType)){this.__record.responseTexttypeofthis.responseobject?JSON.stringify(this.response):this.response;}trigger(xhr,this.__record);});returnoriginalSend.call(this,body);};});replaceOld做的事很窄取出原函数交给你返回一个包装函数再写回去。原函数必须被调用否则整站 AJAX 全断。loadend覆盖成功、失败、abort比只听load完整。跳过自己。默认上报是 XHR POST JSON 到dsn/trackDsn。如果不跳过流程是业务接口失败 → 采集 →send→ 再发一条 XHR这条 XHR 又被包装函数看到 → 再采集 → 再send死循环页面和行为栈一起炸判定是方法为 POST且 URL 包含dsn或trackDsn。业务自己的 GET 打到同一域名仍可以记进行为栈这通常没问题。另外还有filterXhrUrlRegExp命中正则的 URL 整段忽略用来排除埋点无关的轮询、日志通道。isSdkUrl必须用「URL 是否包含配置里的 DSN 字符串」而不是写死路径。DSN 是接入时传入的采集层不能猜服务端长什么样。5. 重写 fetch要clone()失败时 status 记 0fetch没有open/send两段一次调用就要记齐。返回值是 Promise包装函数必须把原 Promise 原样交还给业务不能吞掉。replaceOld(window,fetch,(originalFetch){returnfunction(url,config{}){constsTimeDate.now();constmethod(config.method||GET).toUpperCase();letrecord{type:fetch,method,url,reqData:config.body};returnoriginalFetch.call(window,url,config).then((res){constcopyres.clone();copy.text().then((text){if(methodPOSTisSdkUrl(url))return;if(isFiltered(url))return;record{...record,elapsedTime:Date.now()-sTime,status:copy.status,time:sTime,responseText:copy.status401?text:undefined,};trigger(fetch,record);});returnres;// 业务继续用原来的 body},(err){if(methodPOSTisSdkUrl(url))throwerr;record{...record,elapsedTime:Date.now()-sTime,status:0,time:sTime};trigger(fetch,record);throwerr;// 网络失败仍要抛给业务},);};});Response 的 body 只能读一次。采集侧要看文本必须clone()再text()把没动过的res还给业务。断网、DNS 失败走 reject 分支没有 HTTP status约定记0给后面的整形区分「跨域 / 超时 / 4xx」。和 XHR 一样只在 POST 且命中 DSN 时跳过。Image 打点走的是new Image().src ...根本不进 XHR / fetch 包装所以不会被自己采集——这是换 Image 上报时的副作用不是跳过逻辑的一部分。6. error 和 click必须走捕获阶段重写解决「函数调用」监听解决「浏览器派发的事件」。这两类不能用同一套replaceOld。error。addEventListener(error, handler, true)的第三个参数是捕获。脚本运行时错误会到window.onerror但img、script加载失败是资源错误不冒泡冒泡阶段的window监听收不到。捕获阶段从window往下走能在资源元素上截到再用event.target.localName区分「这是图片失败还是脚本抛错」。区分之后的整形留给后面的篇本篇只要记住资源错误不用捕获就等于没采到。click。同样绑在捕获阶段目标是document。有人会在按钮上stopPropagation()冒泡到不了document捕获仍然能先经过document。回调里用document.activeElement拼出标签摘要tag、id、class、innerTextbody本身丢掉——点空白处没有分析价值。点击会连发。包装trigger时加节流间隔由throttleDelayTime控制默认 0 表示不节流。节流丢的是重复 click不是「这个按钮永远只记一次」。document.addEventListener(click,throttle((e){trigger(dom,{category:click,data:document});},throttleDelayTime),true,);无埋点记下的 click 默认只进行为栈不上报。产品要的「支付按钮点击量」仍然要track(EVENT, { trackId: pay_click })。这里采到的是「出错前他点了button idpay立即支付/button」。7. 路由pushState 不会触发 popstateSPA 切页常常是history.pushState(...)地址栏变了浏览器不会因此触发popstate。popstate只在后退 / 前进时出现。只听popstate单页应用里大半路由都是盲区。所以要同时做两件事重写pushState/replaceState再包一层原来的onpopstate。两边都算出{ from, to }用模块级变量记住上一页letlastHreflocation.href;functionnotify(to){constfromlastHref;lastHrefto;trigger(history,{from,to});}constwrap(original)function(...args){consturlargs[2];if(url)notify(String(url));returnoriginal.apply(this,args);};replaceOld(history,pushState,wrap);replaceOld(history,replaceState,wrap);constoldPopwindow.onpopstate;window.onpopstatefunction(...args){notify(location.href);returnoldPopoldPop.apply(this,args);};from/to同样默认只进行为栈。需要「进入结算页」的曝光统计用track(PAGE)不要把 history 包装当成 PV 方案——它没有产品定义的页面名只有 URL。和前作怎么接篇补哪一段1一次点击如何变成一条埋点整条管线采集 → 整形 → 行为栈 → 上报本篇采集里的无埋点重写、订阅、开关、跳过 DSN、捕获阶段、SPA 路由本篇停在trigger(type, data)。下一层HTTP 失败怎样变成HTTP_ERROR、行为栈怎样按 category 分类还没展开。你可以从这里带走什么无埋点 在原生 API 外包一层。业务不写采集代码业务语义仍然要主动埋点。重写只截获订阅回调才处置。包装函数里直接send采集和上报会焊死DSN 跳过也难做干净。每类事件只包装一次。silentXhr这类开关靠同一套 flag先标记「不要采」订阅就会失败原生函数保持原样。上报是 POST 到dsn/trackDsn的 XHR。采集层必须跳过它们否则自己采自己。资源错误和 click 要用捕获阶段SPA 路由要重写pushState/replaceState不能只听popstate。下一篇前端数据埋点 · 3写行为栈哪些事件只记下不上报出错时为什么要把最近 10 条一起塞进信封。
返回列表