一、现象与背景
先摆数字。我们一个 B 端 SaaS(React 18.2.39 + Vite 5.4 + TypeScript 5.5,约 45 个中后台页面,DAU 约 2.8 万),前端监控 SDK 采集 6 类事件(JS 错误、接口失败、性能指标、资源加载、业务埋点、路由变化),走 sendBeacon 批量上报到网关 /monitor/ingest,下游 Flink 聚合后落 ClickHouse。
2024 年 11 月,值班同学发现一个诡异现象:ClickHouse 里 error_events 表中,约 12% 的记录 user_id 字段为 NULL,session_id 同样为 NULL,但 event_type、route_path、bundle_version 都正常。按 user_id 做聚合的「按用户维度错误 Top-10」报表直接缺失了 12% 的数据,导致两次线上问题的影响面评估偏小。
更诡异的是:这 12% 不是随机的。拉出来看,全部集中在弱网用户(4G 信号 < -100dBm)和移动端 Safari 16.4,而桌面端 Chrome 120+ 一条都没有。按「SDK bug」排查了 3 天没结论。
二、根因分析:JSON.stringify 不是「序列化」,是「选择性丢弃」
定位到根因后,我把它拆成 4 条静默丢失路径。先说机制:
JSON.stringify 的规范(ECMA-262 §25.5.2)对 undefined、Function、Symbol 的处理是静默跳过——不是报错,不是填 null,是这个 key 在输出里直接消失。
路径 1:undefined 值被静默丢弃(主因,占 12% 的 91%)
SDK 的采集上下文里,userId 和 sessionId 来自一个 UserContext 对象:
// context.ts
class UserContext {
private _userId?: string; // 异步加载,首屏时为 undefined
private _sessionId?: string; // 同上
get userId() { return this._userId; }
get sessionId() { return this._sessionId; }
}
userId 是通过一个 /api/user/profile 接口异步拉取的,首屏渲染时 this._userId 是 undefined。SDK 在 DOMContentLoaded 后 200ms 内采集的第一批事件(路由变化、首屏 JS 错误),此时 userId 还是 undefined。
序列化时:
const payload = JSON.stringify({
userId: ctx.userId, // undefined → 这个 key 在 JSON 里消失
sessionId: ctx.sessionId, // 同上
route: location.pathname, // 正常
});
// 输出: {"route":"/dashboard"} ← 没有 userId 字段
下游 Flink 的 JSON.parse 拿到的是 {route: "/dashboard"},user_id 列自然为 NULL。不是「值是 null」,是「字段不存在」。
为什么弱网和 Safari 16.4 集中?因为弱网下 /api/user/profile 的 P95 从 120ms 涨到 2.3s,userId 为 undefined 的窗口从 200ms 拉长到 2.3s;Safari 16.4 的 fetch 首屏缓存策略让 /api/user/profile 命中了 304 Not Modified,响应体为空,SDK 的解析逻辑没处理空 body,_userId 一直是 undefined。
路径 2:toJSON 覆盖静默改变形状(占 7%)
一个第三方风控 SDK(@risk-guard/browser 2.7.0)内部维护了一个 RiskState 对象,其 toJSON 方法返回 {score: 87, flags: []},但 SDK 的 state 属性实际是 {score: 87, flags: [], userId: "u_88231", deviceId: "d_..."}。
JSON.stringify(riskState) 走 toJSON,userId 和 deviceId 直接消失。这不是我们的 bug,是第三方库的 toJSON 实现与属性集不一致。
路径 3:Symbol 和 Function 属性静默跳过(占 2%)
React 18.2 的 Fiber 节点上挂载了 Symbol(react.element) 和 Symbol(react.ref) 属性。我们的一个自定义错误边界在 componentDidCatch 里把 errorInfo.componentStack 序列化上报,其中嵌套了 Fiber 节点——所有 Symbol 属性全部消失,componentStack 从 3 行变成 0 行。
路径 4:BigInt 直接抛 TypeError(占 0%,但最危险)
JSON.stringify(123n) 抛 TypeError: Do not know how to serialize a BigInt。这个不是静默丢失,是直接炸——但它炸的位置在 SDK 的 try-catch 里,被 catch 后整条事件被丢弃(不是部分字段丢失,是整条丢失)。一次 BigInt 就能让一个用户的 5 条事件全部消失。
三、具体做法:3 层序列化防御(可直接抄)
第 1 层:序列化前「字段显式化」
核心原则:不依赖 JSON.stringify 的「自动遍历」,而是显式列出要序列化的字段。
// serializer.ts — 替代裸 JSON.stringify
const FIELD_WHITELIST: Record<string, (ctx: MonitorContext) => unknown> = {
userId: (ctx) => ctx.userId ?? null, // undefined → null,字段保留
sessionId: (ctx) => ctx.sessionId ?? null,
route: (ctx) => ctx.route,
bundleVer: (ctx) => ctx.bundleVersion,
eventTs: () => Date.now(),
riskState: (ctx) => {
// 不依赖 toJSON,显式取属性
const s = ctx.riskState;
return s ? { score: s.score, flags: s.flags, userId: s.userId ?? null } : null;
},
};
function safeSerialize(ctx: MonitorContext): string {
const obj: Record<string, unknown> = {};
for (const [key, getter] of Object.entries(FIELD_WHITELIST)) {
try { obj[key] = getter(ctx); } catch { obj[key] = null; }
}
return JSON.stringify(obj);
}
关键:?? null 不是装饰,是「字段保留」的保险丝。 undefined 被 JSON.stringify 丢弃,null 不会。下游 Flink 拿到 {"userId": null} 而不是 {},user_id 列是 NULL 而不是「字段不存在」——语义完全一样,但可审计性完全不同:你能区分「SDK 上报了 userId=null」和「SDK 根本没上报 userId」。
第 2 层:structuredClone 替代 JSON.parse(JSON.stringify()) 做深拷贝
SDK 里有一处用 JSON.parse(JSON.stringify(event)) 做事件深拷贝(防止后续修改污染已上报的快照)。这个写法会触发 toJSON、丢弃 Symbol、炸 BigInt。
改成:
// 替代 JSON.parse(JSON.stringify(obj))
const snapshot = structuredClone(event);
structuredClone 支持 undefined、Symbol(保留)、BigInt(保留),不支持 Function(抛 DataCloneError,报错而不是静默丢)。在我们 6 类事件里,只有 componentStack 含 Fiber 节点(有 Symbol),structuredClone 能正确处理。
注意:
structuredClone在 Safari 15.4+ 可用。Safari 15.4 以下需要 fallback,但 fallback 必须是「显式字段白名单」而不是JSON.parse(JSON.stringify())。
第 3 层:上报前「字段完整性校验」
在 sendBeacon 之前加一道校验:
function validateBeforeSend(payload: string, requiredFields: string[]): boolean {
let parsed: Record<string, unknown>;
try { parsed = JSON.parse(payload); } catch { return false; }
const missing = requiredFields.filter(f => !(f in parsed));
if (missing.length > 0) {
// 不丢弃事件,降级上报 + 标记
parsed.__degraded = missing.join(',');
payload = JSON.stringify(parsed);
// 走降级通道(P1 采样率 100%,带 __degraded 标记)
}
return true;
}
// 调用
validateBeforeSend(payload, ['userId', 'sessionId', 'eventTs']);
降级不丢弃:字段缺失的事件仍然上报,但打上 __degraded 标记。下游 Flink 按 __degraded 过滤时能看到「有多少事件是降级上报的」,而不是直接 NULL 混在正常数据里。
四、踩过的坑(反直觉之处)
-
Object.assign和展开运算符{...obj}都会保留undefined值,但JSON.stringify不会。 我在调试时console.log({...ctx})看到userId: undefined,以为「值在、只是 undefined」,但JSON.stringify({...ctx})输出里根本没有userId这个 key。console.log和JSON.stringify对undefined的处理不一致,这是最反直觉的。 -
toJSON的优先级高于 getter。 如果一个对象同时有get userId()和toJSON(),JSON.stringify走toJSON,getter 不参与。第三方风控 SDK 的toJSON返回的对象里没有userId,但直接访问riskState.userId是有值的——「能读到」不等于「能序列化出来」。 -
structuredClone不支持Function,会抛DataCloneError。 我们的componentStack里有 Fiber 节点,Fiber 节点上有renderWithHooks等函数引用。structuredClone在这里会炸。解法:在structuredClone之前,用toPlainObject把 Fiber 节点转成纯数据(只保留type、key、props三个字段)。 -
sendBeacon的 64KB 上限在「字段显式化」后更容易触达。 显式白名单意味着你「主动多带了字段」,payload 比之前大。我们riskState展开后从 3 个字段变 5 个,componentStack展开后从 0 行变 3 行——单条 payload 从平均 2.1KB 涨到 3.4KB。64KB 上限在批量上报(单次最多 20 条)时从「永远不会触达」变成「弱网下 0.3% 的概率触达」。字段显式化不是免费的,要同步检查 payload 预算。
五、结论与可复用清单
JSON.stringify 不是「序列化」,是「选择性丢弃」。它的设计假设是「你在序列化一个纯数据对象」,但前端运行时里,对象上挂满了 undefined、Symbol、Function、toJSON 覆盖——这些都不是「纯数据」,而是「运行时的副作用」。
监控数据管道里,「字段不存在」和「字段值为 null」在下游看起来一样(都是 NULL),但语义完全不同:前者意味着「数据在采集端就丢了」,后者意味着「数据到了但值是空」。不区分这两者,你的可观测性大盘上「12% 的 user_id 为 NULL」和「12% 的用户没有 userId」是完全不同的两个结论。
可复用清单(按落地顺序):
undefined→null:所有JSON.stringify的入参,对可能为undefined的字段加?? null(5 分钟,改 3 行代码);- 显式字段白名单:不用
JSON.stringify(ctx),用safeSerialize(ctx)显式列出字段(30 分钟); structuredClone替代JSON.parse(JSON.stringify()):深拷贝场景统一切换,Safari 15.4 以下 fallback 到显式白名单(1 小时);- 上报前字段完整性校验:
validateBeforeSend检查必填字段是否存在,缺失打__degraded标记(30 分钟); - payload 预算检查:字段显式化后,重新计算批量上报的 payload 上限,确保 64KB 不触达(15 分钟);
- 下游 Flink 加
__degraded监控:按__degraded字段聚合,日报告警阈值 >1%(Flink SQL 1 行)。
这套东西的落地成本不高(半天),但它解决的不是「某个字段丢了」,而是**「你无法区分『值是 null』和『字段不存在』」**——后者在可观测性里是最致命的,因为它让「数据缺失」看起来像「数据正常但值为空」。