修好 bug 只是开始:一次由监控需求反向重构日志结构的复盘

修一个线上白屏,大多数人以为"不白屏了"就是终点。但我这次的经历是:方案上线、用户不再白屏之后,真正难的部分才开始------我想知道"到底有多少人受影响、降级有没有生效、有多少人自己刷新恢复了",却发现自己什么都答不上来

明明第一天就加了日志上报,可当我去日志平台里查的时候:先是一条都搜不到 ;好不容易搜到了,又发现字段根本没法用;最后不得不反过来把整个事件结构推倒重来。

这篇文章不讲怎么做 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;服务端日志也确认收到了。响应成功 + 落库成功,不等于你能查到。 这中间有一条容易被忽略的链路:

flowchart LR A[探针上报] --> B[同源日志端点] B --> C[服务端写日志文件] C --> D[采集 agent] D --> E[日志平台] E --> F{按用户维度搜} F -->|探针剥了身份字段| G[搜不到]

日志确实躺在平台里,问题出在我搜的方式 。我习惯性地按"用户/会话"维度去筛(毕竟平时排查都是"某某用户遇到了什么"),可这条探针日志里根本没有用户 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 }
);

几个关键决策:

  1. 把"包名""CDN 域名"在客户端就预派生成独立字段 (pkg / cdnHost),而不是等到查询时再用正则从 URL 里抠。分析侧从此可以直接 group by cdnHost,查询变得又短又稳。
  2. 数组就用数组missingGlobals 原来是 'React,ReactDOM' 这样的逗号拼接字符串,改成真正的数组 ['React','ReactDOM'],下游要展开、要计数都自然。
  3. 稀疏是诚实的 。一个 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),
};

这条教训可以推广成一句话:

判断一个外部依赖"是否可用",要校验它能不能干活 (关键能力/导出在不在),而不是它在不在(变量定义了没)。存在性是最弱的一种检查。

第三幕:让监控能回答"漏斗 / 归因 / 去重"

结构理顺之后,我才回过头想清楚一件本该最先想清楚的事:监控到底要回答什么问题? 只有先定义好问题,才知道该埋什么。

我想回答的是这样一个漏斗:

flowchart LR P[主源失败] --> Q[降级到备源] Q -->|成功| R[正常启动] Q -->|失败| S[兜底页] S -->|刷新| T[恢复] S -->|离开| U[流失]

对着这个漏斗倒推,就知道原来的埋点缺了哪些拼图:

  • 缺"阶段"轴 :原来主源失败和备源失败都叫同一个 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"更值钱的几条经验

这次从"修白屏"到"重构日志结构",绕了不少路,但每一步的弯路都对应一条可迁移的经验:

  1. 可观测性是解决方案的一部分,不是事后附加。 一个修复如果上线后无法度量,你就无法证明它真的生效、也说不清影响面。把"我将来怎么验证它"当成方案设计的一等需求,而不是发完版再补。

  2. 一个字段只承载一个语义。 用"判别字段 + 万能字段"去装 N 种含义,写入时省事,分析时崩溃。日志/埋点结构要正交拆轴 + 类型化,让每一列都可聚合。这条适用于所有埋点,不止 CDN 场景。

  3. 区分"敏感字段"和"关联键"。 数据最小化删的是 token、手机号这类敏感信息;一个匿名的会话/加载 id 不是敏感信息,而是你的排查命脉,别一起删了。

  4. 只埋失败,算不出恢复率。 漏斗有成功侧和失败侧两半。想度量"降级生效比例""刷新自愈比例",就必须显式上报成功/恢复事件(并注意只在异常后上报,别污染健康流量)。

  5. 按事件数统计会放大影响面。 一次故障往往产生多条事件,一定要带上"单次加载"和"用户"两级关联键,用 count(distinct ...) 去重,才能得到真实的用户影响。

  6. 判断依赖"可用",要校验能力而非存在。 typeof x !== 'undefined' 是最弱的检查;失败的产物可能留下一个"存在但残废"的空对象。校验它的关键导出在不在,才靠谱。

  7. 复杂系统的修复是"逼近"而非"一步到位"。 一个看似简单的"加个降级",我先后撞上了竞态、假就绪、日志过载、检索维度错位好几堵墙。别指望第一版就完美;重要的是每撞一次墙,都把教训沉淀成下一次不会再犯的结构或约束。

最后一句想说的是:排查和修复的价值,常常不在那行报错本身,而在于它逼着你把"上报---落库---检索---聚合---归因"这条你以为"记了日志就万事大吉"的链路,从头到尾认真走一遍。很多团队的监控面板看起来很热闹,但真出事时会发现------记了,不等于看得见;看得见,不等于看得清;看得清,不等于能归因。

参考与数据源

相关推荐
kyriewen1 小时前
面试官让我优化一个卡死的表格,我打开 Claude 五秒重写,他放下咖啡问我要不要来当组长
前端·javascript·面试
小粉粉hhh2 小时前
React(二)——dom、组件间通信、useEffect
前端·javascript·react.js
Csvn2 小时前
🤖 AI 生成前端代码的 5 个典型翻车现场及修复指南
前端
IT_陈寒3 小时前
SpringBoot自动装配坑了我一天,原来问题出在这
前端·人工智能·后端
索西引擎4 小时前
【React】key 属性:协调算法中的元素标识机制与最佳实践
前端·javascript·react.js
只会cv的前端攻城狮4 小时前
动态组件架构设计:从配置到事件驱动全链路解析
前端·vue.js
飞天狗4 小时前
Next.js 16 App Router 实战:服务端组件与流式渲染
前端·next.js
OpenTiny社区4 小时前
TinyEngine 2.11版本发布:AI 进一步融入画布,Vue 工程可一键转DSL
前端·vue.js·github
小雪_Snow4 小时前
JavaScript 中的三种常用事件
开发语言·前端·javascript