09|发布与监控
第九章 / 09。目标:证明“哪一版、哪条路由、哪个动作出了什么问题”,并能安全停止扩散。
release id 是连接键
同一个不可变 release id 应进入构建产物、source map 上传、错误事件、性能事件和部署记录。客户端错误不能只报 message:
type ClientEvent = {
kind: 'error' | 'performance'
release: string
routePattern: string
operation: string
at: string
requestId?: string
name: string
message?: string
value?: number
}routePattern 使用 /orders/:id,不要上报含用户 id、查询词或 token 的完整 URL。错误 stack 在发送前也要经过采样、大小和隐私策略;浏览器日志不是可以无限收集的数据仓库。
错误捕获有层级
Vue app.config.errorHandler 捕获组件链错误;window.error 与 unhandledrejection 补充框架外异常;接口错误由请求层记录状态、受控错误码与 request id。它们要去重,不能同一异常上报三次。
window.addEventListener('unhandledrejection', event => {
report(toSafeEvent('error', event.reason))
})监控代码自身失败必须被吞吐/降级,不能递归触发新的全局错误。不要把完整请求体、Cookie、DOM 或用户输入默认附加到事件。
跟着一条错误走完闭环
假设灰度发布 web-20260915.3 后,结算按钮抛出压缩代码中的异常。构建阶段已经把同一个 release 写入产物,并把对应 source map 上传到受控监控平台;浏览器发送的安全事件是:
{
"kind": "error",
"release": "web-20260915.3",
"routePattern": "/checkout",
"operation": "submit-order",
"name": "TypeError",
"message": "Cannot read properties of undefined",
"requestId": "req-7f2"
}平台必须用事件里的 release 查找 同一构建 的 source map,才能把 assets/app-D9x.js:1:18420 还原到 src/CheckoutButton.vue:42。若只按“最新 source map”解析,下一版上线后旧错误会被映射到错误源码;如果客户端包和上传记录没有共同 release,这条证据链在这里就断了。requestId 再把前端动作和服务端失败关联起来,但它不能替代 release:两者回答的问题不同。
接着比较同一路由、同一操作的两组样本:
const baseline = { release: 'web-20260915.2', attempts: 1000, failures: 2, p95Ms: 420 }
const candidate = { release: 'web-20260915.3', attempts: 1200, failures: 36, p95Ms: 460 }旧版失败率是0.2%,候选版是3%。候选已有足够样本,且失败率同时超过1%绝对门槛和旧版的三倍;即使 p95 没有显著恶化,也应停止扩大并回滚。回滚完成不是“部署命令成功”,还要从真实入口取得旧版 HTML,确认它引用的带 hash 资源仍可下载,并观察新流量的事件重新归入 web-20260915.2。最后把 CheckoutButton.vue:42 的回归转成组件或请求层测试,闭环才真正结束。
下面的纯函数把这次示例的判断条件变成可重放输入:
type ReleaseSample = { attempts: number; failures: number; p95Ms: number }
function decideRollout(baseline: ReleaseSample, candidate: ReleaseSample) {
if (candidate.attempts < 500) return 'hold'
const oldRate = baseline.failures / baseline.attempts
const newRate = candidate.failures / candidate.attempts
if (newRate > 0.01 && newRate > oldRate * 3) return 'rollback'
if (candidate.p95Ms > baseline.p95Ms * 1.5) return 'rollback'
return 'continue'
}
console.assert(decideRollout(baseline, candidate) === 'rollback')500、1%、三倍和1.5倍只是本实验明确给定的合同,生产阈值必须来自业务错误预算、历史波动与最小样本设计。把判断写成函数的目的,是让同一份输入能重放,并测试 hold/continue/rollback,不是宣称这组数字适合所有产品。
发布前后是一条协议
构建:typecheck + test + production build + manifest
发布:上传与 release 匹配的私有 source map
灰度:按版本观察错误率、关键操作失败率和性能分布
扩大:只有门槛满足才继续流量
停止:回滚/关闭功能开关,并验证旧版资源可获取
复盘:把真实回归变成低层自动化测试门槛由历史基线、业务量和容错目标决定,不照抄固定百分比。平均值会掩盖尾部和少数设备,应按版本、路由、设备级别看分位数与样本量。
缓存发布要保证 HTML 与带 hash 的静态资源版本兼容;回滚时旧资源不能已经被删除。source map 只上传到受控平台,静态站点请求应返回不可访问结果并实际验证。
练习:安全事件投影与去重
实现 toClientEvent(error, context):仅允许上面的字段,routePattern 来自受控路由记录的 pattern(例如 /orders/:id,禁止直接传 location.href/fullPath);本题允许的 pattern 是 /orders/:id 与 /drafts/new,其他值抛 TypeError。message 最长 200 code points,不接受 user/email/token/query。实现 dedupeKey = release + routePattern + operation + name + normalizedMessage。测试敏感 context 被丢弃、同错误三入口得到同 key、不同 release 不会误合并、监控上报 reject 不会再抛到全局。
再实现正文合同的 decideRollout(baseline, candidate)。输入的 attempts 必须为正整数,failures 是不超过 attempts 的非负整数,p95Ms 是非负有限数;非法输入抛 TypeError。候选少于500次返回 hold;失败率同时大于1%和基线三倍,或 p95 大于基线1.5倍时返回 rollback;其余返回 continue。分别给三个结果写实际断言,并说明回滚后还要观察哪两个外部信号,不能把纯函数返回值当成部署已恢复。
参考答案:第 9 章第 09 题。