跳到主要内容
报 bug 的按钮自己坏了,而你没法上报这件事

报 bug 的按钮自己坏了,而你没法上报这件事

摸鱼办主任
摸鱼办主任

· 阅读约 6 分钟

上个月在 dev.to 上扒到一篇,讲 Element Web 那个"上报问题"的对话框。

【灯光渐暗。用户勾上诊断信息,按下提交】

然后什么都没发生。

准确地说,发生了一件事:提交失败。

于是这位用户手里攥着两个 bug——一个是他本来要报的,一个是"报不了那个"的。而后面这个,他没有渠道上报,因为唯一能上报的按钮,正是坏掉的那个本人。

这个局面的故障等级我定 P0。虽然它实际影响的用户数,统计上应该很难看。

作者查下来,认定问题不在错误处理,在执行顺序:可选诊断信息的采集,被放到了关键路径上。采集一旦失败,整份报告直接不存在。

请注意"可选"这两个字。它在代码里大概是这么一副嘴脸:这些信息有就更好,没有也不影响提交。

——实际表现是,没有就同归于尽。

两条通道各有各的死法,这两段是全篇最有意思的地方。

rageshake 那条,加密信息和恢复信息的采集只要产生一个没被处理的 promise 拒绝,调用方就拿不到本该返回的 FormData。本来该吐一个包裹出来,结果吐了个拒绝。

Sentry 那条更妙。负载是拿对象字面量一次性拼起来的,里面塞着好几个 await。平时它跑得好好的,好到你会以为这就是个普通对象——直到其中任意一个属性被拒绝。

属性被拒绝,对象就不存在。对象不存在,captureException 就不会被调用。

一秒之前它还是个对象,一秒之后它什么都不是。整份报告的生命周期,结束在一个花括号里。

作者在 develop 分支的一个 commit 上让 getOwnDeviceKeys() 返回被拒绝的 promise,人为造了一个确定性的故障。结果很干净:collectBugReport() 在生成 FormData 之前就被拒了,sendSentryReport() 在走到 captureException 之前就被拒了,真实 Sentry SDK 的本地传输收到的 envelope 数量是零。

零。不是"少",是零。

然后我得说一个我最想说的东西。

丢掉的报告不是随机样本。

被这个顺序问题吃掉的,恰好是那些来自加密层本就已经不健康的会话的报告。

翻译一下:所有用户里,唯一被系统静音的,是那批最需要开口说话的人。

这个 bug 不光会丢东西,丢得还挑得特别准,专挑重症室下手。幸存者偏差这东西从来都是反着长的——不是"活下来的都运气好",而是"死掉的那批本来最有话说"。

(这个梗我讲过不止一次了,但每次碰到新的肇事者,还是想再讲一遍。)

再说修复。作者没有用一整块 try/catch 把采集逻辑全包起来。

理由两条,我全都同意。第一,第一个失败会连带把此前已经成功采到的上下文一起丢掉;第二,它会把好几种完全不同的故障,压平成一个匿名的"出了点问题"。

"出了点问题"这五个字,是工程界的"多喝热水"。它表达了关切,它什么都没说。

他选的方案是每个诊断族各设一个失败边界,调用顺序原样不动。重复一小段边界逻辑,换评审者一眼看得懂。大意是:看得懂的重复,好过会悄悄改变调用顺序的共享抽象。

这个取舍我不打算装中立。共享抽象是那种三个月后不敢动、半年后不敢看的物种,而它带来的收益,经常只是让 diff 短二十行。

他后来还想过一版基于 Promise.allSettled 的共享并发采集器,最后把它划到"可能的后续工作"里去了——因为它会改变对加密层和 homeserver 的并发行为,还会把两套本来不同的负载模型合并到一起。这个克制放在今天挺罕见的。

换我的话,那版采集器大概已经在生产环境跑了两周,然后在某个我压根没预料到的场景里,把两套负载模型合并成第三种谁也没见过的形状。所以我说罕见,是真心的。

不过修复的第一版有个坑,我特别喜欢。

第一版通过了作者当时写下的全部测试。全绿。

client.getCrypto() 这个同步调用,还留在边界之外。同步抛出不给你 promise 拒绝的机会,它直接跳过两个 try 块,报告照样提交失败。

——你给异步失败写了三层防护,然后被一个同步调用从背后捅了。

更早一点,他们甚至在对照测量里发现,修复前序列化事件数是 0,修复后是 1。花了不知道多少个晚上,端出来的核心证据是两个数字:一个零,一个一。

还有一个行为变化,我想单独说一下。Sentry 那条路原来用 MatrixClientPeg.safeGet(),在没有 Matrix 客户端这种很常见的情形下会直接抛,一个事件都不产生。改成 get() 之后,事件能带着可用的上下文(通常是 storage)捕获出来,这个场景的事件数从 0 变成了 1。

顺便,catch 块里只记固定字符串,错误消息、堆栈、键名、标识符一概不插。因为 Element 开了 Sentry 的控制台面包屑,rageshake 也会把捕获到的日志附进上报包——错误处理这块要是自己嘴不严,就会连人带错误一起交出去。错误处理自己也得提防别变成一个新的错误来源。

评论里后来还有个不错的延展:如果采集器不是拒绝,而是挂起呢?有人用真实 SDK 加无网络传输实测,Promise.race 一加,报告确实不等了,序列化出恰好一个事件——但那个慢采集器还在跑,只在 envelope 发出去之后才结束。

作者给的结论非常窄:竞速只能证明报告停止等待,不能证明采集器停止。

我欣赏这种窄。窄结论是可信度来源,宽结论只是修辞。他还补了一刀:报告是可以重试的,每次重试都可能对那个已经卡住的子系统再起一个采集器——没有真正取消的截止时间,不是半个方案,是带第二重代价的取舍。

这一段我本来准备展开写两百字的,写到这儿发现想说的已经被他自己说完了。打住,换一个。

最后一个小细节。作者读存储诊断代码的时候发现了一个拼写错误,跟这个缺陷没有共同根因,所以没塞进这次提交。

我尊敬这种克制,因为我做不到。我大概率会顺手改掉,在 PR 描述里补一句"顺带修了个 typo",然后三个月后在 git blame 里被自己逮个正着。

(友情提示:文章是 8 月 10 号发的,那时上游 issue 还开着,挂着 T-Defect 和 A-Feedback-Reporting 两个标签,没有负责人,作者礼貌追问过一次就没再跟。我猜它现在大概还开着。一个"报告提交失败"的缺陷,以 GitHub issue 的形式继续躺在那里,算是走完了它最后一段流程——这次不是发往 Sentry,是发往另一个同样没人接的队列。你有没有那种提交完就再也没人理会的报告?评论区扣个"同款",我们下条再见 🫠)