6.2 全局错误监听与日志集成


6.2 全局错误监听与日志集成

上一节的 await 纪律保证了链上的异常都能到达兜底层,但"到达"不等于"被看见"。值班时真正决定排查速度的是日志质量:能不能按请求 ID 串起一次请求的全部痕迹、能不能一眼分清告警该派给谁、错误率突变能不能及时知道。本节把 app.on('error') 与日志体系的接线方式讲透——错误事件是入口,结构化日志是载体,告警是出口。

日志的三个层级:别让它们混在一个管道里

一个 Koa 服务产生的日志天然分三类,管道必须分开:

层级 内容 归宿 典型消费方式
访问日志 每请求一行:方法、路径、状态、耗时 实时管道 看流量、算错误率、查慢接口
错误日志 异常详情:堆栈、上下文、reqId 持久存储 排障检索、告警触发
业务日志 关键动作:下单、退款、改权限 常加合规要求 审计、对账、行为分析

混管道的代价在第 8 章监控一节会算细账,这里只说结论:访问日志量最大、允许采样;错误日志一条都不能丢;业务日志有合规留存期。三者写同一个文件,错误日志的检索就会被访问日志淹没。

全局错误事件接线:app.on('error') 的完整实现

第 2.4 节的极简版只做了 console.error,生产版要补齐结构化、分级与降噪:

const logger = require('./logger'); app.on('error', (err, ctx) => { // 降噪一:客户端主动断开不告警,但留痕(统计里能看到网络质量) if (err.code === 'ECONNRESET') { logger.warn('client_aborted', { url: ctx && ctx.url }); return; } // 降噪二:已带状态的 HTTP 错误(如上游 404)按 warn 处理 const level = err.status && err.status < 500 ? 'warn' : 'error'; logger[level]('unhandled_error', { name: err.name, message: err.message, stack: err.stack, status: err.status || 500, url: ctx && ctx.url, method: ctx && ctx.method, reqId: ctx && ctx.state.reqId, userId: ctx && ctx.state.user && ctx.state.user.id, }); });

降噪是这层最容易缺的部件。没有它,公网扫描器制造的连接中断能在告警群里刷屏,把真正的 500 淹没——告警的信噪比比告警的完整性更重要,这条在 8.3 节的监控设计里还会反复出现。

请求 ID:贯穿全链的检索主线

日志可检索的前提是每条痕迹都有共同的主键。方案在第 6.1 节已备好:入口层生成 reqId、写进 AsyncLocalStorage、所有日志自动携带。补上入口层与响应头的最后一环:

const crypto = require('crypto'); function requestId() { return async (ctx, next) => { // 优先沿用上游网关生成的 ID,保证跨服务串联(第 9 章网关案例依赖这一点) ctx.state.reqId = ctx.get('x-request-id') || crypto.randomUUID(); await als.run({ reqId: ctx.state.reqId }, next); ctx.set('X-Request-Id', ctx.state.reqId); // 出层回给客户端,报障时用户能报出这串号 }; }

于是排障路径变成机械操作:用户报障带 reqId → 按号检索错误日志拿堆栈 → 同号展开看该请求的完整访问轨迹。这串 ID 同时是 2.4 节错误响应体里的 requestId 字段——三方(用户、前端、后端)说同一件事。

结构化日志的最小实现

不引第三方库也能写出合格的结构化日志器,核心只有"一行一 JSON":

// logger.js const levelWeight = { debug: 10, info: 20, warn: 30, error: 40 }; const MIN = levelWeight[process.env.LOG_LEVEL || 'info']; const { als } = require('./context'); function emit(level, msg, extra = {}) { if (levelWeight[level] < MIN) return; const store = als.getStore() || {}; const line = JSON.stringify({ time: new Date().toISOString(), level, msg, reqId: store.reqId, userId: store.userId, ...extra, }); (level === 'error' ? console.error : console.log)(line); } module.exports = { debug: (m, e) => emit('debug', m, e), info: (m, e) => emit('info', m, e), warn: (m, e) => emit('warn', m, e), error: (m, e) => emit('error', m, e), };

一行一 JSON 的好处在采集端兑现:日志平台按行解析、按字段索引,"level 为 error 且 message 包含 timeout"这类检索是原生能力。反之,自由文本日志的检索全靠正则猜,规模一大就废。LOG_LEVEL 环境变量控制下限,开发环境开 debug、生产只到 info,这个开关在排查疑难杂症时还能临时调低,避免改代码重发版。

告警的接线:让错误主动找人

日志是等人来查的,告警是主动找人的。最小可用告警接在 emit 的 error 分支上:

const alerts = []; function emitError(line) { console.error(line); alerts.push(Date.now()); // 滑动窗口节流:一分钟内错误超过阈值才触发,避免告警风暴 const recent = alerts.filter((t) => Date.now() - t < 60_000); alerts.length = 0; alerts.push(...recent); if (recent.length === 10) { notifyOnCall(`服务错误激增:最近一分钟 ${recent.length} 次,样例 reqId 见错误日志`); } }

两个设计判断值得写进团队规范。其一,按窗口计数而非逐条告警——第一条错误往往只是开始,等满十条再叫人,既滤掉抖动又不会漏掉爆发。其二,告警消息里必须带"下一步去哪查"(样例 reqId、服务名、时间窗),值班同学收到消息后应该一步跳到日志,而不是从零开始找现场。更完整的告警分级(P0 到 P3)与错误率曲线,留给 8.3 节的监控体系。

💡 关键直觉:日志系统的成熟度不看功能多少,看两个问题的答案——"给我一个 reqId,多久能看到这次请求的全部现场?"和"半夜错误激增,多久有人知道?"前者考验结构化与贯穿,后者考验告警接线。两个答案都在分钟级以内,日志体系就算合格。

本节要点回顾

  • 日志三级分管道:访问日志可采样、错误日志零丢失、业务日志守合规,混管道是大忌;
  • 全局错误事件要降噪:ECONNRESET 留痕不告警、4xx 级错误降为 warn,保住告警信噪比;
  • requestId 是检索主线:入口生成(沿用上游)、Als 自动携带、响应头回传,三方对齐一件事;
  • 一行一 JSON:结构化日志让检索按字段走,LOG_LEVEL 环境变量控制输出下限;
  • 告警按窗口计数:滤抖动、防风暴,消息里带 reqId 让值班一步到位。

链内与链外的日志都接好了,最后一站看进程本身:逃过一切兜底的异常、退出信号、长期运行的内存与句柄——进程稳,服务才稳。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U