本人五年的工作经验,历经三份工作,竟然每份都开发维护过前端命令行工具,大家对前端页面和服务端应用有监控告警这件事习以为常,其实这类工具也需要监控告警,本文将从错误处理到上报排查进行分享。
背景
作为前端大家其实都习惯了前端页面有 sentry 类的应用进行错误监控;Node.js 应用打印日志,并在需要的时候使用类 kibana 的应用进行日志查询,且往往配套监控告警。而前端开发几乎每天都会打交道的命令行工具,却在每次报错时,要么联系开发者,要么去用户群咨询。
目的
我们先来看看命令行工具的几个特点:
- 在用户的终端中执行,像一个客户端
- 运行在 Node.js 环境中,像服务端应用
- 用户是开发者,往往不能像一个真实产品一样被运营
基于如上几个特点,命令行工具的监控告警需要有上报处理;错误处理需要借鉴 Node.js 应用;开发者对 bug 的容忍度比较高,善于自行排查,命令行工具的维护者需要主动解决某一类问题,以减少开发者在排查中浪费时间;需要一个反馈机制,使得工具能够越来越 bug less。
作为命令行的维护者,借助监控告警的目的主要有:
- 主动发现异常,提前介入处理,而不是积累到一定程度,用户主动上门时才介入
- 大部分用户往往不会主动上门,如果有替代品或者不是必须使用的话,用户就流失了
- 即使用户没有流失,一个经常出错的命令行工具,会使得用户变得不信任
- 接入监控告警重点是发现『重复类』的错误,解决一批错误而不是偶现错误,从而迅速收敛错误
错误处理
在阐述监控告警之前,有必要重点说明下如何科学地处理程序出现的错误。如果程序侧无法很好地处理和上报错误,那么监控告警将起不到有效的作用。
错误分类
正确地区分错误分类,有助于我们分别采取不同的方式处理错误,错误主要分为:
- 操作性的错误(预期内)
- 程序员的错误(预期外)
这两类错误的区别:
- 『操作性的错误』是程序正常操作的一部分
- 『程序员的错误』是 Bug,往往由于程序员没有正确处理导致
举几个例子:
-
操作性的错误
- 连接不到服务器
- 无法解析主机名
- 无效的用户输入
- 请求超时
- 服务器返回500
- 套接字被挂起
-
程序员的错误
- 读取 undefined 的一个属性
- 调用异步函数没有指定回调
- 该传对象的时候传了一个字符串
- 该传IP地址的时候传了一个对象
不同类别的错误如何处理?
操作性的错误
- 直接处理(处理完成后,继续执行)
- 向上层传错(及时向上层抛错,由上层处理)
- 重试操作(尝试重试,比如重发请求)
- 直接崩溃(崩溃退出进程,比如内存不足)
- 记录错误(仅记录或上报错误)
程序员的错误
- 无法处理(log & crash)
错误上报
我们往往能妥善地处理大部分『操作性的错误』,但是一旦出现无能为力的『操作性的错误』或者『程序员的错误』,此时我们能做的一般只有打印错误告知用户后退出应用,同时将错误上报。接着在服务端根据错误信息区分监控和告警,那么什么样的错误属于告警,什么样的错误属于监控呢?
告警
- 当程序出现『程序员的错误』,需要紧急介入时,比如用户初始化模版时,进程异常退出
- 当程序出现『操作性的错误』,且最终无法被处理时,比如用户创建 gitlab 时,重试次数达到上限,且仍未成功时
监控
- 当程序出现『操作性的错误』,需要引起足够重视,比如用户输入了不合法的路径、用户创建 gitlab 时经常需要重试 2 次才能成功
- 当程序进入需要引起足够重视的逻辑(可以不是错误),比如出现了新用户、新部门、新工程
哪些地方需要上报
预期外的错误
process.on('unhandledRejection', error => {
reportAlarm({ error });
console.error('Unhandled Rejection Error: ', error);
setTimeout(() => process.exit(1), 1000);
});
process.on('uncaughtException', error => {
reportAlarm({ error });
console.error('Unhandled Exception Error: ', error);
setTimeout(() => process.exit(1), 1000);
});
预期内的错误
try {
// 命令行处理逻辑
} catch (error) {
console.error('Handled Error: ', error);
await reportAlarm({ error });
process.exit(1);
}
错误排查
有效的错误上报,对错误的监控告警处理起到了决定性的作用,我们来看看两则上报消息:
Bad case
错误模块:模版初始化
错误信息:输入路径非法
错误栈:/Users/linleyang/code/temp/case.js:2
throw new Error('输入路径非法');
^
Error: 输入路径非法
at init (/Users/linleyang/code/temp/case.js:2:9)
at Object.<anonymous> (/Users/linleyang/code/temp/case.js:5:1)
at Module._compile (internal/modules/cjs/loader.js:999:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
at Module.load (internal/modules/cjs/loader.js:863:32)
at Function.Module._load (internal/modules/cjs/loader.js:708:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
at internal/main/run_main_module.js:17:47
Good case
错误模块:模版初始化
错误信息:输入路径非法
错误栈:/Users/linleyang/code/temp/case.js:2
throw new Error('输入路径非法');
^
Error: 输入路径非法
at init (/Users/linleyang/code/temp/case.js:2:9)
at Object.<anonymous> (/Users/linleyang/code/temp/case.js:5:1)
at Module._compile (internal/modules/cjs/loader.js:999:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
at Module.load (internal/modules/cjs/loader.js:863:32)
at Function.Module._load (internal/modules/cjs/loader.js:708:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
at internal/main/run_main_module.js:17:47
输入值:$/home/linleyang
触发用户:林乐扬
这两个示例,下面那个除了上报错误消息和错误栈之外,还上报了足够多的错误上下文,我们称之为错误现场,有了错误现场,我们便可能自行复现,或者阅读源码就能解决问题,错误现场既然如此重要,我们来看看一般可以上报哪些现场信息:
- 错误发生时的入参
- 错误发生时的状态(关键变量)
- 触发人
- 错误信息/错误栈
- 环境信息(SCM,CI,本地) ......
我们正确处理了错误,正确上报了现场,这样服务端便可根据这些信息将错误分发到各个模块负责人,示例如下:
模版初始化 | 载入模版失败 | 错误报警
模块负责人
@林乐扬
基本信息
Version: 1.0.1 Node: v12.22.5 Env: local
Project: https://github.com/quanru/bagu
用户信息
姓名: @林某某
首次使用的时间: 2021-08-31 05:00:29
最近使用的时间: 2021-11-23 06:00:00
历史执行次数: 111
部门信息
名称: XX部门
首次使用的时间: 2021-05-24 04:03:43
使用的总人数: 21
信息链接: https://quanru.github.io/
错误信息
级别: 未设置
信息:
Cannot read property 'replace' of undefined
近 24 小时内该用户出现该错误信息的次数: 4
近 24 小时内该用户出现所有错误信息的次数: 4
近三个月该用户出现该错误信息的次数: 4
近三个月所有用户出现该错误信息的次数: 26
错误栈:
Error: 输入路径非法
at init (/Users/linleyang/code/temp/case.js:2:9)
at Object.<anonymous> (/Users/linleyang/code/temp/case.js:5:1)
at Module._compile (internal/modules/cjs/loader.js:999:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
at Module.load (internal/modules/cjs/loader.js:863:32)
at Function.Module._load (internal/modules/cjs/loader.js:708:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
at internal/main/run_main_module.js:17:47
上下文信息
输入值:$/home/linleyang
附加信息
command: cra init hello-world
上报策略
为了让命令行维护者更聚焦的处理错误,请切记『监控告警』:
- 只提供一个通道,用户需要酌情上报
- 都上报和都不上报区别不大
服务侧需提供默认的上报策略:
这些策略提供参数供上报侧设置
- 同一个人,半小时内仅上报一次
- 同一个人,首次无需上报
- 不存在上下文信息和附加信息的降级为监控
上报侧需要考虑:
- 调试流量/测试版本不上报
- 上报侧应主动区分监控和告警(比如:npm start)
- 用户目录下的代码改动,可以是监控
- 我们的模块代码出错,应该是告警
- 尽量主动处理预期内错误,减少抛到兜底处理(比如:某个 npm 包未安装)
- 按需配置『上报策略』
- 发生主动异常退出 (process.exit(1)) 时,使用 console.error 而不是 console.log,这样父进程能获取到 stderr 标准错误输出
- spawnSync 子进程时,将标准错误 pipe 到父进程进行处理