修一个线上白屏,大多数人以为"不白屏了"就是终点。但我这次的经历是:方案上线、用户不再白屏之后,真正难的部分才开始------我想知道"到底有多少人受影响、降级有没有生效、有多少人自己刷新恢复了",却发现自己什么都答不上来。
明明第一天就加了日志上报,可当我去日志平台里查的时候:先是一条都搜不到 ;好不容易搜到了,又发现字段根本没法用;最后不得不反过来把整个事件结构推倒重来。
这篇文章不讲怎么做 CDN 降级和兜底页(那是另一件事),只讲一个常被忽视的命题:可观测性是解决方案的一部分,不是事后附加 ;以及一个几乎每个团队都在犯、却很少有人纠正的错误------日志字段的语义过载。为方便讨论,下文把出问题的应用称为"某单页应用(SPA)",它把 React 全家桶通过 CDN 外置为全局变量,主源失败时会降级到备用源,全都失败时渲染一个兜底页。
起点:一段"极简到查不到"的上报
问题的背景是这样:因为最严重的崩溃发生在"应用还没起来"的时候,常规错误监控 SDK(打包在主 bundle 里)根本来不及初始化,抓不到。所以我在 HTML 里放了一段独立于 bundle 的内联探针,专门捕获这类"框架之前"的异常并上报。
上报时我很"克制":探针触发的时刻,应用还没启动,很多上下文(比如会话 ID)还没写进 cookie,于是我干脆只上报了诊断必需的几个字段:
ts
// 第一版:极简到"以为够用"
report({
biz: 'app-boot',
kind: 'boot_gate_timeout',
detail: 'React,ReactDOM,Router,Query', // 仍缺失的全局
path: '/some-page',
ua: navigator.userAgent,
});
这个"数据最小化"的直觉本身没错,但它埋了一个雷:上报侧的"极简"和查询侧的"可检索"是一对矛盾。 我删掉的恰恰是日后检索、聚合、关联所必需的键。
第一幕:响应 200、落库成功,却"查不到"
上线后我去日志平台搜,结果:0 条。
我的第一反应是"是不是没上报成功"。但翻网络面板,请求明明返回了 200;服务端日志也确认收到了。响应成功 + 落库成功,不等于你能查到。 这中间有一条容易被忽略的链路:
日志确实躺在平台里,问题出在我搜的方式 。我习惯性地按"用户/会话"维度去筛(毕竟平时排查都是"某某用户遇到了什么"),可这条探针日志里根本没有用户 ID、没有会话 ID ------就是我上一节亲手"极简"掉的那些字段。于是任何按身份收敛的查询,都会系统性地把它漏掉。它不是没上报,是天生不可被我的查询命中。
这里有第一条教训,而且它很反直觉:
"数据最小化"是对的,但要区分"敏感字段"和"关联键"。删敏感字段(token、手机号)是安全;删关联键(一个匿名的会话/加载标识)是把自己的排查能力也一起删了。
顺带一提,这条链路还解释了另一个常见困惑:为什么"这条日志"和"那条业务埋点"字段结构完全不同------因为它们经由不同的写入方式落库(一个是手写 fetch 的裸 JSON,一个是走统一 SDK 序列化),平台侧看到的形状自然不一样。排查前先搞清楚"这条日志是谁、以什么结构写进来的",能省很多时间。
第二幕:搜到了,却"没法用"------字段语义过载
补上关联键、终于能稳定搜到日志之后,我兴冲冲地去建监控表格,结果撞上了更根本的问题:字段的语义是混乱的。
回头看那个 detail 字段------它的含义取决于 kind:
ts
// 同一个 detail 字段,三种完全不同的语义
report({ kind: 'script_error', detail: 'https://cdnA.example.com/react.min.js' }); // 是 URL
report({ kind: 'gate_timeout', detail: 'React,ReactDOM,Router' }); // 是全局名列表
report({ kind: 'runtime_error', detail: 'React is not defined' }); // 是错误消息
这在类型系统里叫由判别字段(kind)驱动的联合类型(union type) 。写入的时候它很"省事",一个字段什么都能装;但到了列式的日志分析里,它就是一场灾难:
- 表格里
detail这一列,同一列里既有 URL、又有逗号分隔的全局名、又有报错文本,没法排序、没法聚合、没法过滤。 - 我想派生一个"CDN 域名"列做归因(从 URL 里提取 host),结果对那些
detail是全局名列表的行,派生结果全是 null------一半的行,这一列毫无意义。 - 我想统计"哪个包最常加载失败",发现根本无从 group by,因为"包名"这个语义从来没有被独立表达过,它被埋在 URL 字符串里、还和别的语义挤在一起。
用户视角的原话是:"我在表格里看到的数据,有一堆字段在不同语义下是无意义的。" 这句话精准地描述了语义过载的症状。
这是本文最想传递的一条教训,而且它远不止于这个 CDN 场景------它适用于任何埋点/日志设计:
一个字段只承载一个语义。 不要用一个"万能字段"配一个"判别字段"去装 N 种含义。写入时省的那点事,会在下游分析时连本带利还回来。
怎么改:正交拆轴 + 类型化字段
病根是:原来的结构把几个正交的维度 塞进了 kind + detail 两个字段。kind 里其实混着两个独立的轴------一是"这是哪一类问题"(资源加载失败 / 就绪缺失 / 运行时报错 / 恢复成功),二是"发生在哪个阶段"(主源 / 备源 / 就绪门 / 运行时)。而 detail 则被迫承担了三种互斥的载荷。
重构的方向是把这些轴拆开,每个语义给一个具名、类型化的字段,谁适用谁有值:
ts
type BootEvent = {
// 公共轴
biz: 'app-boot';
category: 'resource_error' | 'readiness_gap' | 'js_error' | 'recovery'; // 大类
stage: 'primary' | 'fallback' | 'none' | 'onload' | 'gate' | 'runtime' | 'boot'; // 阶段
path: string;
aid: string; // 持久匿名 id(跨刷新)
lid: string; // 单次加载 id
} & (
// 类型化的稀疏载荷:每类只带自己需要的字段
| { category: 'resource_error'; resourceUrl: string; cdnHost: string; pkg: string }
| { category: 'readiness_gap'; missingGlobals: string[]; waitedMs?: number }
| { category: 'js_error'; errorMessage: string }
| { category: 'recovery'; sinceGateMs?: number }
);
几个关键决策:
- 把"包名""CDN 域名"在客户端就预派生成独立字段 (
pkg/cdnHost),而不是等到查询时再用正则从 URL 里抠。分析侧从此可以直接group by cdnHost,查询变得又短又稳。 - 数组就用数组 。
missingGlobals原来是'React,ReactDOM'这样的逗号拼接字符串,改成真正的数组['React','ReactDOM'],下游要展开、要计数都自然。 - 稀疏是诚实的 。一个
readiness_gap事件没有resourceUrl,那一列就是空------但这个"空"是"本类事件不适用",语义清晰;而不是原来那种"这一列有时是这个意思、有时是那个意思"的混乱。
改完之后,表格里每一列的含义都是单一的、可聚合的。我甚至可以按 category 拆成几张表,每张表里的每一列都对这张表的每一行有意义。
一个魔鬼细节:"存在"不等于"就绪"
在重构就绪判定时,我还踩到一个独立但同样典型的坑,值得单独说。
就绪门原本用"全局变量是否存在"来判断依赖是否加载好:
ts
// 看似合理,实则不够
function ready(name) {
return typeof window[name] !== 'undefined';
}
但有些库的 UMD 产物是这种写法:先把全局对象占位成空对象,再由工厂函数往里填充导出:
js
// 某些库 UMD 的浏览器分支(示意)
(root.SomeLib = {}), factory(root.SomeLib, root.React);
// 若 root.React 缺失,factory 在填充导出前就抛错
// 结果:root.SomeLib 停在 {} ------ 一个"存在但残废"的空对象
于是当它的依赖(比如 React)缺失时,工厂在填充导出之前 就抛错了,全局变量停在一个空对象 {} 。typeof 检查会认为"它存在、已就绪",于是就绪门放行、应用启动,然后崩在 SomeLib.SomethingUndefined。
修法是把"存在性检查"升级为"能力检查"------校验它关键导出是否真的在:
ts
const ready = {
React: (g) => !!(g && g.createElement),
ReactDOM: (g) => !!(g && (g.render || g.createRoot)),
Router: (g) => !!(g && g.Route),
};
这条教训可以推广成一句话:
判断一个外部依赖"是否可用",要校验它能不能干活 (关键能力/导出在不在),而不是它在不在(变量定义了没)。存在性是最弱的一种检查。
第三幕:让监控能回答"漏斗 / 归因 / 去重"
结构理顺之后,我才回过头想清楚一件本该最先想清楚的事:监控到底要回答什么问题? 只有先定义好问题,才知道该埋什么。
我想回答的是这样一个漏斗:
对着这个漏斗倒推,就知道原来的埋点缺了哪些拼图:
- 缺"阶段"轴 :原来主源失败和备源失败都叫同一个
kind,根本分不清用户卡在漏斗哪一层。补了stage之后,漏斗每一层直接对应一个stage值。 - 缺"成功侧"事件 :原来只记录失败(降级失败、兜底),从不记录"降级成功了""刷新后恢复了"。可漏斗的一半是成功路径------只埋失败,你永远算不出恢复率 。于是补了
recovery这一大类:降级成功、加载内恢复、看到兜底页后刷新恢复(带上距离上次兜底的时长)。为了不给每次健康加载都记一条,这些成功事件只在"确实经历过异常之后"才上报。 - 缺"去重"能力 :这是最隐蔽的。我一度想直接数事件条数当影响面,结果发现一个用户的一次失败加载,会产生十来条事件 (几个脚本各报一次 + 就绪缺失 + 兜底......)。按事件数统计,影响面被严重放大。真正需要的是按"加载"和"用户"去重------这正是前面补的
lid(单次加载 id)和aid(持久匿名 id)的价值。
有了这三样,漏斗查询就变得很直接(用类 SQL 表达):
sql
-- 按"加载"和"用户"去重的阶段漏斗
SELECT stage,
count(distinct lid) AS loads, -- 多少次加载卡在这层
count(distinct aid) AS users -- 多少个用户
FROM boot_events
GROUP BY stage
ORDER BY loads DESC
顺着同一套字段,还能自然地回答:哪个 CDN 域名、哪个包失败最多(group by cdnHost, pkg);同一个用户是不是反复中招(group by aid having count(distinct lid) > 1);降级成功率、刷新恢复率(拿 recovery 事件的去重加载数比上失败的去重加载数)。
注意这里的 aid 是一个匿名的随机 id(存在本地、跨刷新稳定),不含任何个人信息------它让你能做"用户级"的漏斗与去重,又不碰隐私红线。这正是第一幕那条教训的正解:该删的敏感字段照删,该留的匿名关联键要留。
复盘:比"修好 bug"更值钱的几条经验
这次从"修白屏"到"重构日志结构",绕了不少路,但每一步的弯路都对应一条可迁移的经验:
-
可观测性是解决方案的一部分,不是事后附加。 一个修复如果上线后无法度量,你就无法证明它真的生效、也说不清影响面。把"我将来怎么验证它"当成方案设计的一等需求,而不是发完版再补。
-
一个字段只承载一个语义。 用"判别字段 + 万能字段"去装 N 种含义,写入时省事,分析时崩溃。日志/埋点结构要正交拆轴 + 类型化,让每一列都可聚合。这条适用于所有埋点,不止 CDN 场景。
-
区分"敏感字段"和"关联键"。 数据最小化删的是 token、手机号这类敏感信息;一个匿名的会话/加载 id 不是敏感信息,而是你的排查命脉,别一起删了。
-
只埋失败,算不出恢复率。 漏斗有成功侧和失败侧两半。想度量"降级生效比例""刷新自愈比例",就必须显式上报成功/恢复事件(并注意只在异常后上报,别污染健康流量)。
-
按事件数统计会放大影响面。 一次故障往往产生多条事件,一定要带上"单次加载"和"用户"两级关联键,用
count(distinct ...)去重,才能得到真实的用户影响。 -
判断依赖"可用",要校验能力而非存在。
typeof x !== 'undefined'是最弱的检查;失败的产物可能留下一个"存在但残废"的空对象。校验它的关键导出在不在,才靠谱。 -
复杂系统的修复是"逼近"而非"一步到位"。 一个看似简单的"加个降级",我先后撞上了竞态、假就绪、日志过载、检索维度错位好几堵墙。别指望第一版就完美;重要的是每撞一次墙,都把教训沉淀成下一次不会再犯的结构或约束。
最后一句想说的是:排查和修复的价值,常常不在那行报错本身,而在于它逼着你把"上报---落库---检索---聚合---归因"这条你以为"记了日志就万事大吉"的链路,从头到尾认真走一遍。很多团队的监控面板看起来很热闹,但真出事时会发现------记了,不等于看得见;看得见,不等于看得清;看得清,不等于能归因。