Vue 3 项目一旦上线运行,控制台日志、接口异常、用户行为埋点等数据会持续产生。如果把这些日志全量上报到服务端,一个中等规模的应用每天就可能产生千万级日志事件,存储费用、带宽费用和查询性能都会成为负担。日志采样通过只上报一部分日志事件来控制数据量,但简单的随机采样很容易把偶发但价值极高的错误日志也一起丢掉。这篇文章结合 Vue 3 的工程化实践,介绍如何在 Vite 项目中搭建一套分层采样体系,做到高价值日志全量保留、低价值日志按比例丢弃。

为什么需要分层采样而不是简单随机采样
最朴素的采样方式是设置一个全局采样率,比如 10%,每产生 100 条日志只上报 10 条。这种方式实现简单,但问题也很明显:一条指向线上严重故障的 TypeError 报错,和一条普通的页面浏览记录,被丢弃的概率是一样的。当采样率为 10% 时,一个每天只发生 50 次的隐蔽错误,可能只有 5 条日志被上报,排查问题时样本严重不足,甚至可能一条都采不到。
分层采样的思路是把日志按价值分级,不同级别应用不同的采样策略。在 Vue 3 项目中,日志大致可以分为三类:第一类是错误日志,包括 Vue 的 app.config.errorHandler 捕获的渲染错误、未捕获的 Promise 异常、接口 5xx 错误;第二类是警告日志,例如接口响应缓慢、组件渲染耗时异常;第三类是信息日志,比如页面访问、按钮点击等行为埋点。错误日志应该全量上报,警告日志可以适度采样,信息日志则可以大胆地低采样率丢弃。
这种分层的价值在于成本敏感度和信息价值的匹配。行为埋点类日志占了日志总量的绝大多数,通常超过 80%,对这部分数据降采样,数据量立刻能降一个数量级,而排查问题最关键的错误链路却完全不受影响。
在 Vue 3 中实现日志分级采集
先在 Vue 3 应用入口处挂接全局错误捕获,这是错误日志的主要来源。通过 app.config.errorHandler 可以拦截组件渲染和事件处理中的异常,再配合 window.addEventListener('unhandledrejection') 兜底捕获 Promise 异常,两条通道收集的错误都标记为 error 级别,走全量上报。
import { createApp } from 'vue'
import App from './App.vue'
const logger = createSampledLogger()
const app = createApp(App)
// 捕获 Vue 组件内的渲染与生命周期错误
app.config.errorHandler = (err, instance, info) => {
logger.error('vue_error', {
message: err.message,
stack: err.stack,
component: instance?.$options?.name,
lifecycleHook: info
})
}
// 捕获未处理的 Promise 异常
window.addEventListener('unhandledrejection', (event) => {
logger.error('unhandled_rejection', {
reason: String(event.reason)
})
})
app.mount('#app')接下来实现采样日志器本身。核心是一个 shouldSample 函数,根据日志级别读取对应的采样率配置,再决定这条日志是否上报。采样率配置建议放在 Vite 的环境变量中,这样不同环境可以有不同的策略,比如测试环境全量上报方便复现问题,生产环境才启用降采样。
// logger.js
const sampleRates = {
error: 1.0, // 错误日志全量上报
warn: 0.5, // 警告日志上报一半
info: 0.1 // 行为埋点只上报十分之一
}
function shouldSample(level, extra) {
const rate = extra.forceReport ? 1.0 : sampleRates[level] ?? 1.0
return Math.random() < rate
}
export function createSampledLogger() {
const send = (payload) => {
// 使用 sendBeacon 避免阻塞页面卸载
navigator.sendBeacon('/api/logs', JSON.stringify(payload))
}
return {
log(level, event, data = {}) {
if (!shouldSample(level, data)) return
send({
level,
event,
data,
ts: Date.now(),
page: location.pathname
})
},
error(event, data) { this.log('error', event, data) },
warn(event, data) { this.log('warn', event, data) },
info(event, data) { this.log('info', event, data) }
}
}这里的 forceReport 参数给业务保留了手动豁免的能力。比如支付失败这类关键业务事件,即使业务上定义为 warn 级别,也应该强制上报,避免采样把关键转化链路的数据丢掉。
基于用户哈希的确定性采样
随机采样有一个隐蔽的统计问题:同一个用户在多次访问中被采样的概率相互独立,导致单个用户的完整行为序列很难被连续观测。更推荐的做法是基于用户标识做哈希采样,也就是对用户 ID 取哈希后取模,命中固定区间的用户全量上报,其他用户完全不上报。
function hashUserId(id) {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
// 采样率 10%:哈希值落在前 10% 区间内的用户全量上报
function isSampledUser(userId, rate) {
return hashUserId(String(userId)) % 10000 < rate * 10000
}
function shouldSample(level, extra) {
if (level === 'error') return true
const userId = extra.userId || 'anonymous'
return isSampledUser(userId, sampleRates[level])
}这种确定性采样有两个好处。第一,同一用户的所有日志要么都在、要么都不在,分析用户行为漏斗时不会出现中间断链的情况;第二,采样结果可复现,排查某个用户的问题时,可以预先判断这个用户是否在采样范围内,避免在查询平台上白费力气。
需要注意匿名用户的处理。没有登录态的用户可以退化为基于设备指纹或者 Session ID 的哈希,保证同一个会话内的行为连续可追踪,会话之间则自然分散。
采样后的数据量还原与统计偏差处理
采样上线后,监控面板上的数字就不再是真实值了。如果直接把上报量当作实际量来计算错误率、转化率,结论会系统性偏离真实情况。正确的做法是给每条被采样的日志附带一个权重标记,即采样率的倒数,统计时乘以权重还原估算值。
send({
level,
event,
data,
ts: Date.now(),
weight: 1 / sampleRates[level], // 权重:采样率的倒数
sampled: sampleRates[level] < 1.0
})在查询侧,估算某个事件的真实数量时,用 SUM(weight) 代替 COUNT(*)。例如 info 级别的采样率是 0.1,每条日志权重为 10,上报了 3400 条则估算真实量约 34000 条。当然要清楚这是无偏估计,小样本事件的估算误差会比较大,所以对于占比低但重要的指标,应该提高对应的采样率甚至全量采集,而不是依赖统计还原。
最后建议把采样率配置做成动态下发而非硬编码。通过一个轻量的配置接口拉取当前生效的采样率,出现线上事故时可以临时把 warn 级别提到全量,帮助快速定位问题,平时再降回低采样率控制成本。在 Sentry 等平台中,tracesSampleRate 和 sampleRate 也支持传入函数动态判断,可以结合用户白名单实现同样的效果。这样一套体系下来,日志数据量通常能下降 70% 以上,同时错误排查能力不受任何影响,成本和可观测性可以兼得。