后端有完善的监控,不代表用户看到的页面是好的。我们给一个企业官网做改造时就闹过笑话:某个活动落地页的表单按钮因为一次发版引入的 JS 报错彻底点不动了,后端接口一切正常、服务器指标一片飘绿,结果整整三天没有一条咨询进来,还是销售在自己手机上点开才发现"提交"按钮是死的。后端监控看不到前端的崩溃,这就是前端可观测性的缺口。这篇讲我们怎么从零搭起一套轻量的前端异常采集、日志上报和告警分级体系,把"用户先发现问题"变成"我们先发现问题"。
很多人一上来就引一个大而全的监控 SDK,结果日志收了一堆,真正有用的没几条。我们踩过坑之后把信号收敛成四类,每一类解决的问题都不一样:
把这四类分开采集、分开统计,后面告警才知道该叫醒谁,而不是所有异常都一刀切地半夜打电话。
现代浏览器其实已经提供了足够的原生钩子,不依赖重型框架也能把错误兜住。关键是 window.onerror、unhandledrejection 和资源错误的捕获阶段监听,三者覆盖的场景不同,缺一不可:
const report = (type, payload) => {
const body = JSON.stringify({
type,
msg: payload.message,
src: payload.filename || location.href,
line: payload.lineno,
col: payload.colno,
stack: (payload.error && payload.error.stack) || '',
ua: navigator.userAgent,
t: Date.now()
});
navigator.sendBeacon('/log/collect', body); // 卸载页面也能发出去
};
// 1. 同步运行时错误
window.addEventListener('error', (e) => {
// 资源错误没有 error 对象、target 是具体标签,要区分开
if (e.target && e.target !== window) {
report('resource', { message: `资源加载失败: ${e.target.src || e.target.href}` });
} else {
report('js', e);
}
}, true); // 捕获阶段,才能拦到资源加载错误
// 2. Promise 未捕获拒绝(fetch 异步错误主要靠它)
window.addEventListener('unhandledrejection', (e) => {
report('promise', { message: String(e.reason), error: { stack: e.reason && e.reason.stack } });
});接口异常则包一层 fetch,统一记录状态码和耗时,注意不要把 token、用户手机号这类敏感字段带进日志:
const rawFetch = window.fetch;
window.fetch = async function (...args) {
const start = performance.now();
try {
const res = await rawFetch.apply(this, args);
const cost = Math.round(performance.now() - start);
if (!res.ok || cost > 3000) {
report('api', { message: `${args[0]} ${res.status} ${cost}ms` });
}
return res;
} catch (err) {
report('api', { message: `${args[0]} 网络失败`, error: err });
throw err;
}
};最早我们直接用 fetch 发日志,结果发现页面跳转、关闭时的最后一批错误全丢了——请求还没发出去页面就卸载了。换成 navigator.sendBeacon 之后问题解决:它由浏览器保证在页面卸载期间异步、可靠地把数据 POST 出去,且不阻塞跳转。
服务端收到后写进消息队列再落库,避免日志高峰直接压数据库。一个简单的接收与聚合逻辑大致是这样:
def collect(request):
log = parse(request.body)
# 按 页面+错误信息+行号 做指纹,相同错误聚合而不是每条都存
fp = md5(f"{log['src']}|{log['msg']}|{log['line']}".encode()).hexdigest()
redis.incr(f"err:{fp}:{today()}") # 计数
redis.expire(f"err:{fp}:{today()}", 172800)
redis.hset(f"err:detail:{fp}", mapping={
"msg": log["msg"][:200], "src": log["src"], "last": log["t"]
})
queue.push("frontend_log", log) # 明细异步落库
return {"code": 0}做指纹聚合非常关键:一次发版故障可能瞬间产生上万条一模一样的错误,不聚合的话告警会被刷爆,真正不同的问题反而被淹没。
还有两个容易被忽略的点。一个是采样率:官网流量上来之后,正常情况下的埋点和性能日志没必要全量上报,我们对 P2 类常规信号按 10% 采样,只对 JS 错误和接口失败保持 100% 采集,既不丢关键问题,也把日志成本压了下来。另一个是白屏判定,这是"没报错但页面是空的"这种最隐蔽故障的唯一抓手。我们的做法是在页面加载完成后延迟几秒,检查几个关键挂载点是否真的渲染出了内容:
function detectBlankScreen() {
setTimeout(() => {
const roots = ['#app', '#main', '.content'];
const empty = roots.every(sel => {
const el = document.querySelector(sel);
return !el || el.children.length === 0;
});
if (empty) report('blank', { message: '核心挂载点无内容,疑似白屏' });
}, 3000);
}那次"按钮点不动三天没人知道"的事故,如果当时有白屏和 JS 错误采集,报错第一分钟就会进 P0 通道,而不是等销售自己发现。
我们按"是否影响核心转化 + 影响面 + 是否突增"把告警分成三级,这也是从一次告警疲劳里学来的——最初任何报错都推消息,几天后人就对红点免疫了:
判断"突增"要用环比而不是固定阈值,因为官网流量白天晚上差很多。夜里两条报错可能就占了 100%,白天同样两条只是噪声,按错误率环比波动来告警才不会误报。
前端可观测性的价值,是让团队比用户更早知道页面坏了。落地不用追求一步到位:先用原生钩子把四类信号采全,再用 sendBeacon 保证卸载不丢、用指纹聚合保证不被淹没,最后按影响面做三级告警,把人力留给真正影响转化的 P0。这套东西搭起来不复杂,但它补上的,正是"后端一片绿、用户却点不动"那个最危险的监控盲区。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。