上一篇无埋点——重写原生 API相关一次点击如何变成一条埋点系列前端数据埋点· 第 3 篇先说结论无埋点截获到一次点击、一次成功的请求、一次路由变化默认并不发 HTTP。先推进内存里的短队列。这条队列就叫行为栈Breadcrumb面包屑沿路撒下的记号用来原路找回。真正需要人看的时候——脚本抛错、captureException、业务track()——再把当前整段队列拷进信封一起发出去。行为栈错误事件 /track(EVENT)何时发 HTTP当时不发当时发解决什么出错前他做了什么复现告警、点击量、曝光条数有上限满了丢掉最老的一条就是一次上报点击统计仍然靠EVENT。行为栈里的 click 只回答「报错前他点了button#pay」。1. 截获之后为什么不立刻上报第 2 篇停在包装函数喊一声「xhr / click / history 发生了」。如果每一次点击都 POST 到 DSN流量会把收集服务打满一页里点击、请求远比错误多。服务端仍看不到「这些点击和后面那次报错的关系」只是一堆散点。所以分成两步先记在本地出事时整段带走。记的内容要短DOM 路径、method、url、status不要把请求体、Cookie 默认塞进去。2. 概念词典词是什么解决什么行为栈 / Breadcrumb内存里最近 N 条用户轨迹点击、跳转、请求、console出错时有前文不只是一句 errorcategory这条轨迹的分类如ui.click、xhr、fetch、navigation后端按类型展示、过滤beforeBreadcrumb进栈前的钩子。返回null表示本条不要去掉无用点击、脱敏hint递给钩子的「原始材料」如鼠标事件、xhr 对象默认不进信封钩子判断要不要丢这条时能看到完整对象而不把对象写进行为栈maxBreadcrumbs队列最多留多少条常见默认 100内存和信封体积截断单条message超过长度就剪掉实现里常见 2048 字符一条超长 innerText 把内存和上报包撑爆3. 谁进栈谁立刻发无埋点截获到的技术事件职责不同事件默认去向原因点击、按键只进栈太密用来复现不是点击量pushState/ 后退只进栈只有 URL不是产品定义的「进入结算页」XHR / fetch成功只进栈证明「刚才打过这个接口」XHR / fetch失败进栈并且再发一条错误事件要告警console.error等只进栈调试轨迹window.onerror/ 没 catch 的 Promise进栈并且发错误事件稳定性track(EVENT)/captureException发业务或错误事件信封里带上当前整段行为栈统计或排查打到 DSN 的那次 POST 自己不要进栈否则行为栈里全是 SDK 上报把真正的用户轨迹挤掉。第 2 篇说的「认出自己的请求」在这里落地。4. 一条面包屑长什么样字段很少够画出时间线即可{category:ui.click,// 从哪来message:body form button#pay,// 人能读的摘要level:info,// info / warning / error给展示用timestamp:1730000000,// 发生时间data:{method:POST,url:/api/pay,status_code:500},// 可选结构化细节}点击通常只有categorymessageDOM 路径。HTTP 用data带 method、url、status默认不带 body。body form button#pay不是手写的产品名是 SDK 顺着 DOM往上爬拼出来的。页面若是bodyformbuttonidpay立即支付/button/form/body用户点了按钮。无埋点拿到的是浏览器的点击事件event.target就是那个button。然后对当前节点做两件事再走向父节点最多爬几层实现里常见 5 层最后倒序用连起来标签名小写button有id就加#pay有 class 再加.xxxparentNode→form再上一层 →body数组此时是[button#pay, form, body]reverse().join( )得到body form button#pay摘自packages/browser-utils/src/htmlTreeAsString.tswhile(currentElemheightMAX_TRAVERSE_HEIGHT){nextStr_htmlElementAsString(currentElem,keyAttrs);// 当前节点button#payout.push(nextStr);currentElem_safeRead(currentElem,parentNode);// 走向父节点}returnout.reverse().join(separator);// 从根到叶body form button#pay所以这条字符串的意思是被点中的是 id 为 pay 的 button它在 form 里form 在 body 里。不是路由也不是trackId。爬太深或拼起来超过长度就停避免整页 HTML 进行为栈。5. 进栈钩子 → 截断 → 丢掉最老的采集回调里调用addBreadcrumb。还没init则直接返回。maxBreadcrumbs 0等于关掉行为栈。摘自packages/core/src/breadcrumbs.tsexportfunctionaddBreadcrumb(breadcrumb:Breadcrumb,hint?:BreadcrumbHint):void{constclientgetClient();if(!client)return;const{beforeBreadcrumbnull,maxBreadcrumbsDEFAULT_BREADCRUMBS}client.getOptions();if(maxBreadcrumbs0)return;consttimestampdateTimestampInSeconds();constmergedBreadcrumb{timestamp,...breadcrumb};// 进栈前最后一次改写返回 null 表示本条不要constfinalBreadcrumbbeforeBreadcrumb?consoleSandbox(()beforeBreadcrumb(mergedBreadcrumb,hint)):mergedBreadcrumb;if(finalBreadcrumbnull)return;isolationScope.addBreadcrumb(finalBreadcrumb,maxBreadcrumbs);}hint 是什么。addBreadcrumb有两个参数第一个breadcrumb要进栈的那份摘要category、message、data以后会拷进信封。第二个hint这次采集时手里的原始对象给beforeBreadcrumb(breadcrumb, hint)看一眼用。例如点击时 hint 里是浏览器的MouseEventHTTP 时可能是 xhr 实例和请求体。钩子可以据此决定返回null不要这条或改写message。hint 本身不会变成行为栈的一条也不会默认进信封——完整 DOM 节点、完整 xhr 塞进去体积和隐私都扛不住。captureException(err)里也有一个 hintEventHint道理一样originalException留给beforeSend看原始 Error真正 POST 出去的仍是整形后的那份event。consoleSandbox的意思是跑这段钩子时暂时不要再触发被包装过的console避免「记 console → 又触发 console → 再记」打转。真正放进数组时单条message会剪到 2048 字符。超过maxBreadcrumbs就只留最后 N 条最老的扔掉摘自packages/core/src/scope.tsconstmergedBreadcrumb:Breadcrumb{timestamp:dateTimestampInSeconds(),...breadcrumb,// 行为栈常驻内存message 理论上可以无限长必须截断message:breadcrumb.message?truncate(breadcrumb.message,2048):breadcrumb.message,};this._breadcrumbs.push(mergedBreadcrumb);if(this._breadcrumbs.lengthmaxCrumbs){this._breadcrumbsthis._breadcrumbs.slice(-maxCrumbs);// 只留最末 N 条}这就是「先记下」还没有 HTTP。列表活在 SDK 为当前页面准备的那份数据上作用域 / Scope。刷新页面这份列表就没了——行为栈不负责跨会话统计。6. 上报时整段拷进信封这里有两个都叫 event 的东西不要混。名字是什么出现在哪浏览器的event点击、报错时浏览器给你的那个对象如MouseEvent、ErrorEvent无埋点回调里的handlerData.eventaddBreadcrumb的 hint 里也会带上不进信封即将上报的eventSDK 用 Error / 业务字段新拼出来的一份普通对象准备 POST 出去captureException、track()走发送管线时即将上报的那份一开始只有「这次出了什么事」。例如captureException(err)会把Error收成{exception:{values:[{type:TypeError,value:Cannot read property x of undefined,stacktrace:{frames:[...]}}],},}window.onerror也是同一条路先把浏览器的ErrorEvent整形成上面这种对象再发送。业务track(EVENT, { trackId: pay_click })则是另一份对象字段是actionType/trackId不是exception。行为栈此时还在内存的数组里和这份对象是分开的。发送前有一步「准备事件」把作用域里攒着的那一列面包屑写到这份对象的breadcrumbs字段上。摘自packages/core/src/utils/scopeData.tsfunctionapplyBreadcrumbsToEvent(event:Event,breadcrumbs:Breadcrumb[]):void{// event 上面新拼的那份错误或业务// breadcrumbs 内存里最近 N 条轨迹constmergedBreadcrumbs[...(event.breadcrumbs||[]),...breadcrumbs];event.breadcrumbsmergedBreadcrumbs.length?mergedBreadcrumbs:undefined;}拷完之后这一份对象才像{exception:{values:[{type:TypeError,value:...,stacktrace:{...}}]},breadcrumbs:[{category:navigation,data:{from:/cart,to:/checkout}},{category:ui.click,message:body form button#pay},{category:xhr,data:{method:POST,url:/api/pay,status_code:500}},],user:{id:u_1},}然后再装进信封 POST。服务端按时间看到先跳转、再点击、再接口 500最后才是这条脚本错误。点击进栈时第二个参数整个就是 hint。里面的event是鼠标事件只给beforeBreadcrumb看不是第 6 节里那份即将上报的对象摘自packages/browser/src/integrations/breadcrumbs.tsconstbreadcrumb:Breadcrumb{category:ui.${handlerData.name},// 例如 ui.clickmessage:target,// htmlTreeAsString 拼出的路径};addBreadcrumb(breadcrumb,{event:handlerData.event,// 浏览器 MouseEvent只放 hint不进信封name:handlerData.name,global:handlerData.global,});fetch 结束且 URL 带上报用的 key、方法是 POST则直接 return不调用addBreadcrumb。SDK 自己的上报不能挤占这 100 个名额。7. 体积是硬约束信封整体常有约 1MB 的上限超了整包会被服务端丢掉。所以限制是叠在一起的限制典型值挡住什么条数默认 100记太久包太大、内存涨单条 message2048 字符按钮 innerText、异常对象 toString 无限长DOM 路径再短一截常见几十到几百字符htmlTreeAsString往上爬几层就停请求体默认不进data密码、token、整页 HTMLmaxBreadcrumbs调到 500 并不能「复现更久」更容易让整包被拒。需要更长时间线用 Session Replay 或自己的日志而不是把行为栈当录像。beforeBreadcrumb适合丢掉「点了空白」或剪掉 url 上的 token。不要在这里再发一次 HTTP——它跑在每次点击的路径上。和前作怎么接篇补哪一段1一次点击如何变成一条埋点整条管线2无埋点——重写原生 API截获停在triggerHandlers本篇截获之后进栈、截断、上报时拷进信封下一篇写上报本身信封怎么打包、没被改过的fetch怎样发出去、采样和限流。你可以从这里带走什么行为栈是本地短队列不是一次 HTTP。点击、成功请求、路由默认只进栈。出错或track()时把当前整段队列拷进信封用来复现不是用来统计点击量。满了丢掉最老的单条 message 要截断请求体默认不要进栈。SDK 自己打到 DSN 的 POST 不要进栈否则把用户轨迹挤掉。beforeBreadcrumb返回null可丢掉本条不要在钩子里再发上报。仓库与相关文档机制例子sentry-javascriptpackages/core/src/breadcrumbs.ts、packages/core/src/scope.ts、packages/browser/src/integrations/breadcrumbs.ts相关前作1一次点击如何变成一条埋点 · 2无埋点——重写原生 API