外部服务状态查询与后台轮询
本功能定期读取外部服务商的状态页,将服务状态、子服务组件和近期事件归一化为进程内快照,再通过概览与详情接口提供给页面。访客请求不触发上游查询,因此上游请求量与页面访问量解耦。
目的与范围
本文覆盖服务商注册、后台初始化与定时刷新、Statuspage / incident.io 数据转换、故障降级、内存快照及 HTTP 读取接口。重点面向排查“状态为何未更新”“某家服务详情为何缺失”和新增服务商的开发者。
网络连通性探测、DNS 诊断、通用缓存中间件、全站启动框架和 Sentry 配置不属于本文范围。运行时未提供同级目录的具体链接,因此不构造未验证的 Wiki 路径。前端组件代码、通用请求超时实现和测试文件本次未读取;下文仅说明已验证的后端行为。
概述
实现分为两条彼此独立的路径:
- 写入路径:启动时调用
bootstrapServiceStatus(),随后startServiceStatusPolling()每 5 分钟发起刷新;每家服务同时请求summary.json和incidents.json,处理失败与旧值回退后整体发布快照。 - 读取路径:概览只提取
id、name、page、indicator;详情返回单家服务的components与incidents。处理器不执行网络请求。
当前注册 16 家服务:Claude、ChatGPT、Cursor、GitHub、ElevenLabs、Cloudflare、Groq、Notion、Vercel、Netlify、Render、Supabase、Discord、Figma、Linear 和 Stripe。它们使用兼容的 /api/v2/summary.json 与 /api/v2/incidents.json 数据接口。这里呈现的是服务商状态页报告,不是从用户浏览器到服务商的实时连通性测试。
依据:service-status-providers.js、service-status.js。
架构
Sources: backend-server.js、service-status-store.js、service-status-transform.js、service-status.js。
这种分层将网络失败与数据形状转换分开:轮询器决定是否使用旧数据,纯函数负责字段裁剪和默认值,接口只负责输出已缓存数据。概览与详情拆开可以避免首次读取携带全部组件和事件;详情将组件与事件合并返回,减少展开单家服务时的请求数。
初始化、轮询与快照发布
1. 初始状态与就绪判断
模块初始化时,snapshot 为 { updatedAt: null, providers: [] },schedulerStarted 为 false。isServiceStatusPrimed() 仅判断 updatedAt !== null。
这意味着 primed 表示至少完成过一轮快照发布,不表示所有上游均查询成功。首次刷新即使所有服务都被归一化为 unknown,只要刷新流程完成,就会写入时间戳并视为就绪。
bootstrapServiceStatus() 等待一次刷新,成功记录 Service status cache primed;发生异常则记录警告而不向外重新抛出。首次失败时快照仍为空,后续定时任务可以继续填充。若手工再次调用初始化函数,失败也不会清空已有快照。
依据:service-status-store.js、service-status-store.js。
2. 单家服务的双端点请求
fetchJson(url) 调用 fetchUpstream(url),对非成功 HTTP 响应抛出消息为 upstream ${res.status} 的 Error,否则调用 res.json()。网络错误和 JSON 解析错误也会让此 Promise 拒绝。
refreshProvider(provider, previous) 使用 Promise.allSettled 同时等待摘要与事件接口。两个请求的失败分别转换为 null,因此摘要失败不会直接取消事件请求,事件失败也不会阻止读取摘要。
1 const [summary, incidents] = await Promise.allSettled([
2 fetchJson(`${provider.api}/api/v2/summary.json`),
3 fetchJson(`${provider.api}/api/v2/incidents.json`),
4 ]);
5 const summaryJson = summary.status === 'fulfilled' ? summary.value : null;
6 const incidentsJson = incidents.status === 'fulfilled' ? incidents.value : null;Source: service-status-store.js。
3. 整批并发与整体替换
每轮刷新开始时,从当前快照建立 prevById,再并发刷新所有配置中的服务商。只有所有服务的刷新 Promise 均完成,才给 snapshot 赋新值。
1export async function refreshServiceStatus() {
2 const prevById = new Map(snapshot.providers.map((p) => [p.id, p]));
3 const providers = await Promise.all(
4 STATUS_PROVIDERS.map((p) => refreshProvider(p, prevById.get(p.id))),
5 );
6 snapshot = { updatedAt: new Date().toISOString(), providers };
7 return snapshot;
8}Source: service-status-store.js。
由此产生几个重要语义:
- 刷新期间,读请求继续获得上一份完整快照,而不是半更新的服务列表。
- 返回的服务顺序遵循
STATUS_PROVIDERS,不是网络响应完成顺序。 updatedAt是整轮发布的时间,而不是每家服务最后一次成功获取数据的时间。- 最慢的服务请求影响整轮发布时间;某家返回的旧值也可能随本轮新时间戳发布。
- 如转换阶段意外抛出异常,
Promise.all拒绝,本轮不执行快照赋值;已发出的其他请求并不会因该拒绝而自动取消。
4. 后台调度生命周期
startServiceStatusPolling() 用模块级布尔值防止重复创建定时器。它本身不立即刷新,而是注册 setInterval;首次填充由初始化函数承担。定时器调用 .unref?.(),避免它单独维持 Node.js 进程存活。
每次定时执行通过 withCronMonitor('service-status-refresh', ...) 包装,传入 5 分钟的 interval 调度以及 checkinMargin: 5、maxRuntime: 5。包装调用拒绝时,记录 service-status refresh tick failed。这些监控参数不能被当作网络请求超时或强制取消机制;工具内部实现本次未读取。代码注释说明未配置后端 DSN 时监控为 no-op。
故障降级的实际控制流
Source: service-status-store.js。
| 情况 | 发布结果 | 需要注意的语义 |
|---|---|---|
摘要获取失败,旧 indicator 不是 unknown | 返回整个旧服务条目 | 即使本轮事件成功,也不会合并新事件,因为已经提前返回 |
摘要获取失败,没有旧条目或旧值为 unknown | 继续归一化,摘要成为 unknown、组件为空 | 新事件若获取成功仍可使用;记录警告 |
| 摘要成功,事件获取失败,旧事件非空 | 使用新摘要和旧事件 | 组件状态和事件可能来自不同刷新轮次 |
| 事件接口成功返回空数组 | 使用空事件列表 | 不恢复旧事件,因为 incidentsJson 对象仍为真值 |
摘要响应对象存在,但缺少 status.indicator | 转换为 unknown | 不触发“摘要获取失败”的旧值回退 |
所谓 last-known-good 的判断仅是 previous.indicator !== 'unknown',并非仅保留正常的 none。旧值为 major 或 critical 也会保留。这可以减少瞬时网络抖动导致的状态闪烁,但没有最大陈旧时间;持续失败时,旧状态可能跨多轮保留。
1 if (!summaryJson) {
2 const hadGood = previous && previous.indicator !== 'unknown';
3 if (hadGood) {
4 logger.debug({ provider: provider.id }, 'service-status refresh blip; serving last-known-good');
5 return previous;
6 }
7 logger.warn({ err: summary.reason, provider: provider.id }, 'service-status summary fetch failed (no prior data)');
8 }Source: service-status-store.js。