上一节的 await 纪律保证了链上的异常都能到达兜底层,但"到达"不等于"被看见"。值班时真正决定排查速度的是日志质量:能不能按请求 ID 串起一次请求的全部痕迹、能不能一眼分清告警该派给谁、错误率突变能不能及时知道。本节把 app.on('error') 与日志体系的接线方式讲透——错误事件是入口,结构化日志是载体,告警是出口。
一个 Koa 服务产生的日志天然分三类,管道必须分开:
| 层级 | 内容 | 归宿 | 典型消费方式 |
|---|---|---|---|
| 访问日志 | 每请求一行:方法、路径、状态、耗时 | 实时管道 | 看流量、算错误率、查慢接口 |
| 错误日志 | 异常详情:堆栈、上下文、reqId | 持久存储 | 排障检索、告警触发 |
| 业务日志 | 关键动作:下单、退款、改权限 | 常加合规要求 | 审计、对账、行为分析 |
混管道的代价在第 8 章监控一节会算细账,这里只说结论:访问日志量最大、允许采样;错误日志一条都不能丢;业务日志有合规留存期。三者写同一个文件,错误日志的检索就会被访问日志淹没。
第 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 节的监控设计里还会反复出现。
日志可检索的前提是每条痕迹都有共同的主键。方案在第 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,多久能看到这次请求的全部现场?"和"半夜错误激增,多久有人知道?"前者考验结构化与贯穿,后者考验告警接线。两个答案都在分钟级以内,日志体系就算合格。
链内与链外的日志都接好了,最后一站看进程本身:逃过一切兜底的异常、退出信号、长期运行的内存与句柄——进程稳,服务才稳。