最早的监控是一台服务器上跑着 Puppeteer:开一个无头浏览器,登录宿主系统,定时抓几个接口,算指标,超阈值就告警。
它能用,代价是:
- 一台常驻机器;
- 一套登录态维护(会话过期了要有人去重新登录);
- 一个没人看的进程——它挂了,没人会第一时间知道。
后来整个搬进了浏览器扩展的 Service Worker。数据抓取在用户自己的浏览器里发生,登录态天然就是用户的,服务器可以关掉了。
先确认:它到底是不是 WebSocket 推送
搬之前要回答一个问题:宿主的监控页面是怎么拿数据的?如果是 WebSocket 推送,那就得在扩展里维持一条长连接。
用 DevTools 抓了一段网络:是 HTTP 轮询,6 秒一轮,每轮 10 个接口。
页面上确实有几条 WebSocket,但那是 SDK 的心跳,不是数据通道。
这个结论决定了一切:纯 HTTP 轮询是可以在 SW 里做的,而且我们 5 秒一轮就已经追平了宿主页面自己的刷新频率。
(SW 怎么才肯活着跑 5 秒一次的定时器,是另一篇:Service Worker 不肯活着。简单说:有 UI 连着就可靠,没 UI 连着就别跑。)
为什么放在 background,不放 content
两个理由,第二个是硬的:
- 只需要一份。 放 content 的话,每个标签页、每个 frame 各跑一份轮询——同一组接口被打好几遍。
- 有几个端点是跨域的。 从 content script 发会被 CORS 拦;从 background 发,配上
host_permissions就行。
所以结构是:
background/monitor/ auth.ts ← 会话凭据 / CSRF / 跨域端点的签名链,带缓存和失败冷却 *-fetcher.ts ← 每类数据一个抓取器,互相独立 parsers.ts ← 纯函数:原始响应 → 结构化数据 hub.ts ← 状态中枢:持有最新快照,通过 Port 广播,写 storage.session scheduler.ts ← 快慢两档定时器 + alarms 兜底 + 没订阅者就不跑
content/monitor/ bridge.ts ← 长连接订阅 hub,本地缓存最新快照 panel.ts ← 渲染parsers.ts 全是纯函数——这是整个模块里最值得保持的一条。解析逻辑没有副作用,就能脱离浏览器单独测,也能搬到任何地方去跑。
能搬到服务器上的,和不能搬的
后来有人问:既然都是 HTTP 了,能不能再搬回服务器,交给一个定时任务?
逐个看了所有抓取器,答案分两半:
能搬的:监控域的几条只读链路。它们全是纯 HTTP,不需要页面、不需要 Chrome API。
不能搬的:填单、自动回复、知识库这些——它们依赖宿主页面的 React DOM(fiber、富文本编辑器、按 label 找字段)。服务器上没有页面,不是「难移植」,是根本不存在等价的 HTTP 面。
而能搬的那一半,有一个绕不过去的卡点:
所有凭据都派生自浏览器里那个已登录的会话 cookie。
跨域端点的签名、另一个服务的 JWT,全是用这个 cookie 换来的。宿主系统没有 API key,也没有服务账号。
所以在服务器上跑,要么人工粘 cookie 定期续,要么让扩展定期把 cookie 推过去,要么在服务器上做无头登录(涉及口令自动化,没采纳)。
而 cookie 会过期——任何这样的定时任务都必须把「凭据失效」当常态处理:遇到 401 就停轮询、通知人,绝不自动重试,否则会把接口打爆。
这也反过来说明了为什么放在扩展里是对的:扩展是唯一一个天然持有有效会话的地方。
数据以谁为准:只取一个源
有个指标(比如「当前空闲人数」),可以从两个接口拿:
- 一个是宿主给的汇总接口,直接返回各状态的计数;
- 另一个是人员明细,可以自己数。
最初两个都用了——汇总接口没来就自己数。
问题在于:两个接口不是同一时刻取的。某个人在两次请求之间换了状态,自己数出来的和宿主给的就对不上。于是面板上的数字会在两个值之间跳。
现在是:快照只取汇总接口,明细只用来展示列表,不用来计数。
同一个指标只能有一个数据源。 两个源互为兜底听起来很稳,实际效果是数字不一致、而且你没法判断哪个是对的。
一个「抽样」差点被当成统计
这条值得单独说。
面板上有一个「积压排行」:按受理人统计手上有多少未处理的单。旧链路是这样的:
翻页拉全量 → .slice(0, 20) 只留 20 条 → 面板拿这 20 条 groupBy 受理人积压有上百条时,那 20 条是「第一页的前 20 个」——既不是全量,也不是随机抽样。排名和真实分布毫无关系。
面板上其实标了一个「⚠ 抽样」。但用户读到的结论仍然是错的——没有人会因为一个小标签就去怀疑一张排行榜。
修法是在 background 做全量聚合,只把聚合结果发给面板。翻页封顶或中间某页失败时,结果带一个 complete: false,面板如实显示「部分统计」。
「标了抽样」不等于「用户知道这是抽样」。 如果一个数字不能用来下结论,就别把它做成排行榜的样子。
面板为什么卡:每次推送都全量重建 DOM
面板有一段时间明显卡。查下来是两件事叠在一起:
① 推送次数太多。 hub 有 8 个 setter,每个都单独广播一次。慢周期一轮 5 个抓取器回来就是 5 次推送,快周期再来 2 次。
② 每次推送都全量重建。 旧的 render() 每次都同步重建三大块 innerHTML——包括 5 张明细表、上百个 <tr>。
而面板 99% 的时间是折叠着的。那些 DOM 建完立刻被 display: none 藏起来。
两条修法:
// ① rAF 合帧:同一帧内多次推送只画最后一次let pending: Hub | null = null;function onHub(hub: Hub) { const first = pending === null; pending = hub; if (first) requestAnimationFrame(() => { const h = pending!; pending = null; render(h); });}
// ② 折叠态不建明细:展开的瞬间补画,收起时清空function setExpanded(on: boolean) { expanded = on; if (on) renderDetail(lastHub); else detailEl.replaceChildren();}看不见的 DOM 也是要付钱的。
display: none省的是绘制,不省构建。
告警走用户提醒,不走日志
最后一个设计点:阈值告警(空闲不足、接通率低、数据停止更新)走的是用户可见的提醒通道,而不是调试日志。
这两套是完全独立的——调试日志默认关闭,给开发者看;用户提醒不受任何开关影响。关掉调试日志不能把告警也关掉。(这条在日志层那篇里单独写过。)
「数据停止更新」这个告警尤其重要:轮询因为会话过期静默失败的时候,面板上的数字不会变成空的——它会停在最后一次成功的值上,看起来一切正常。没有这个告警,用户会对着一组过期数据做判断。
从一台 Puppeteer 服务器搬到一个 Service Worker,省掉的不只是一台机器。
更重要的是省掉了「凭据」这个问题:服务器上的机器人需要自己维护一个登录态,而扩展直接用用户的。前者是一个需要人照看的东西,后者不是——用户登录着,它就能工作;用户没登录,它本来也不该工作。
有时候架构的正确位置,就是那个天然拥有它所需资源的地方。