React 项目中 日志采集 的设计取舍
起因是上个月线上出了个事儿,有个用户反馈说点提交按钮没反应,我让他打开控制台截图发过来,好家伙,一排红色的 Unhandled Rejection。可我自己这边怎么都复现不了,翻了半天代码才反应过来,那块逻辑在 try/catch 里把错误吞了,只打了个 console.error,而生产环境的 console 早就被构建脚本干掉了... 那一刻我才意识到,这项目压根没有日志采集,出了问题全靠用户嘴说、开发者玄学复现,不行的。
于是决定给项目补一套前端日志采集。动手之前纠结了不少,坑也踩了不少,这篇就把这些取舍记下来,免得下次又要重新想一遍。
先想清楚要采什么
一开始想法很朴素:把所有报错都抓上来不就完了吗。真动手了才发现不是这么回事儿...
window.onerror 能抓大部分同步错误,unhandledrejection 能抓 Promise 里没 catch 的拒绝,这两个是底线,必须挂上。React 这边还有个特殊的地方:组件渲染时抛的错,onerror 其实收得到,但你拿不到组件栈,只知道 Cannot read properties of undefined,不知道是哪个组件炸的,所以 ErrorBoundary 里的 componentDidCatch 也得单独上报一份。另外 ErrorBoundary 管不到事件回调和 setTimeout 里的错,所以前面那两个全局钩子还是不能省,两边都得挂。
接口报错最后是放在 axios 的拦截器里统一采的,只记 URL、状态码和后端返回的业务 code。响应体别打全量,一个太大,另一个里面没准儿有用户数据,采回去还得操心脱敏的事儿。
性能数据(FP、LCP 那些)本来也想一起采,后来想了一下,这项目连性能预算都没有,采回来也没人看,先砍了。日志这事儿,采了没人看的数据比不采还糟糕,纯纯浪费上报流量。
用 Sentry 还是自己写
这是纠结最久的一个点。Sentry 确实好用,聚合、告警、source map 都给你做好了,但私有化部署得自己维护一套服务,SaaS 版又卡在数据合规上过不去,我们内网环境连装个依赖都费劲。
最后选了个折中:照着 Sentry 的思路自己写个简化版,说白了就是后端按 message 加堆栈前 N 行做个 hash 聚合,同一个错误超过阈值就往群里发条通知。前后大概花了两天,糙是糙了点,但胜在可控,想加字段就加字段。
如果你们没有这些合规上的破事儿,我真心建议直接上 Sentry,别学我,自己造的轮子后患无穷... 但如果条件跟你一样拧巴,简化版真够用了。前端日志这东西,八成的价值在“报错能被看到”,剩下两成才在那些花哨功能。
上报通道和时机
这步踩的坑最多,展开说说。
一开始上报用的 fetch,跑了一阵发现日志会丢,用户关页面那一刻,还躺在队列里没发出去的就全没了。后来换成 navigator.sendBeacon 才好,这玩意儿就是为这个场景设计的,页面卸载了浏览器也会尽力给你发出去。但 sendBeacon 只能 POST,个别老浏览器还没有,所以兜底逻辑是:有 beacon 用 beacon,没有就 fetch 加上 keepalive: true,实在不行降级成 new Image().src 打点(只适合塞个 id 之类的短日志,别把堆栈往 URL 里拼)。
然后是批量。别来一条发一条,不然灰度期间一天几百万条请求能把后端同学吓死。我的做法是内存里开个队列,攒够 10 条或者每 5 秒 flush 一次,另外监听 visibilitychange,页面切到 hidden 的时候强制 flush 一次,这个基本能覆盖关页面的场景。
采样、脱敏和 source map
全量上报坚持了不到一周就扛不住了,某天一个低版本 WebView 上的死循环报错,一晚上给我打了小两百万条,聚合那边磁盘差点爆了...
最后的策略是:错误日志全量(错误总量本身可控,聚合后并不多),行为日志(点击、路由跳转这些)按 10% 采样,而且按用户 id 哈希决定采不采,保证同一个用户要么全采要么全不采。不然排查问题的时候日志断断续续的,比没有还难受。还有个小细节,开发环境记得把上报关掉或者标记 env,不然本地调试也往线上灌脏数据,而且 React 18 的 StrictMode 下 effect 会故意跑两遍,行为日志会直接翻倍。
脱敏没做太复杂,上报前把 message 里长得像手机号、身份证、邮箱的字符串直接替换掉,接口日志只记 URL 不记 query 的值。粗糙,但够用,先跑起来再说。
source map 是最大的坑。没有它,收到的堆栈全是 chunk.3f2a1.js:1:238471 这种,看得人头皮发麻。做法是 CI 发版的时候自动把 sourcemap 传到日志平台,然后从构建产物里删掉再发布——千万别把 sourcemap 部署到线上 CDN,那等于把源码公开了。另外记得给每次构建打个版本号或者 build id,上报的时候带上,不然堆栈对不上是哪个版本发的包,传了也白传。
最终落地的样子
核心其实就一个 logger 模块,大致长这样:
const queue = []
function log(level, msg, extra = {}) {
queue.push({ level, msg, extra, ts: Date.now(), page: location.pathname, release: RELEASE })
if (queue.length >= 10) flush()
}
function flush() {
if (!queue.length) return
const body = JSON.stringify(queue)
if (navigator.sendBeacon && navigator.sendBeacon('/api/log', body)) {
queue.length = 0
} else {
fetch('/api/log', { method: 'POST', body, keepalive: true }).finally(() => { queue.length = 0 })
}
}
初始化的时候挂四个东西就完事儿了:window.onerror、unhandledrejection、axios 拦截器、ErrorBoundary,加上上报一共不到两百行,没引任何第三方库。
写在最后
回头看,这套东西没什么高深的技术,全是在做取舍:采多少、怎么传、保真还是保量。我的结论是别想着一步到位,先把“线上报错能看到”这个最低目标实现,剩下的需求等真的痛了再加。没痛过就加的需求,八成是白加...
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。