如何在代码中打日志

之前对于代码中的console.log一直是比较嫌弃的,以致于提交代码前一般会通过eslint检测是否包含了log输出。

最近一直在处理一个chrome插件的需求,需要打开某个url标签页,然后根据预先设定的操作行为,在页面上进行一些列操作(点击选项卡、选择日期、输入参数等),然后等待页面生成报表,实现自动下载的流程。整个过程较为繁琐,很不方便调试,在开发中使用了大量的console追踪页面的状态和操作行为的结果,逐渐认识到:日志应该是开发和调试中很重要的一环。因此在这里整理一下“如何在代码中打日志”的问题。

<!--more-->

本文仅涉及与开发相关的日志,如代码流程、错误日志监控等,与业务相关的打点、流量统计等方面暂未涉及,也不包括log4js等日志框架的使用。参考

1. 日志的分类与分级

1.1. 日志的分类

从代码的运行时间段对日志进行分类

  • 在调试阶段,日志的主要作用是检查代码的逻辑执行,,包括记录代码执行流程、参数、执行结果等,这种日志一般是开发人员用于调试代码,不会出现在生成环境
  • 在生产环境,日志的主要作用是为了快速准确定位并解决问题,这种日志一般记录程序的运行状况,特别是非预期的行为、异常情况,这种日志主要是给开发、维护人员使用

通过日志的分类,我们可以了解到什么时候需要打日志

  • 部分重要的核心功能,需要通过日志确认代码执行的整个流程和结果,当遇见某些重要的if...else分支时,此时需要通过日志来确认代码进入哪个分支

  • 在代码异常时抛出的错误,预期代码应该是不会发生错误的,只能通过debug功能来确定问题,此时需要打日志来记录代码中的异常,良好的系统,是可以通过日志进行问题定为的。

1.2. 日志的分级

如果一个应用的日志同时也分了多个级别,那么可以很轻易地分析得到该应用的健康状况,及时发现问题并快速定位、解决问题,补救损失。

关于日志的分类,stackoverflow上有一个讨论,总结一下,理论上越靠前的等级越严重,越应该被记录

  • 紧急错误 fatal,表示需要立即被处理的系统级错误,这属于最严重的日志级别

  • 错误 error,影响到程序正常运行、当前请求正常运行的异常情况,如

    • 资源依赖加载失败导致程序无法启动
    • 代码抛出未处理的异常,导致程序终止
  • 警告 warn,应该出现但是不影响程序、当前请求正常运行的异常情况,一般打在有容错机制的时候出现的错误情况,如

    • 请求参数未按照预定格式传入,
    • 系统资源占用达到警戒线
  • 信息 info,记录程序运行的信息,如流程步骤、调用参数、调用结果等,如

    • 调用第三方服务时,info信息是十分必要的,因为我们很难追溯到第三方模块发生的问题
    • 对于复杂的业务逻辑,需要记录程序的运行流程,检测程序是否按正常逻辑执行
  • 调试 debug,用于开发调试代码,可以记录想要知道的全部信息,但是不能一直出现在生产环境,需要通过开关控制或者在上线前移除调试日志

  • 追踪 trace,特别详细的系统运行完成信息,一般不要出现在生产环境

关于日志的分级,一种观点是分级应该尽可能详细,从而更精细地过滤和处理日志;而另外一种观点是:日志应该只有info、error两个级别,多余的级别基本上只会造成混乱。

更极端的观点是:日志根本不应该分级,在开发过程中,日志并不是调试的唯一手段,但是在定位生产环境问题的时候,日志是唯一的依据,输出日志最终的价值就是体现在定位现场问题。如果仅仅只是为了通过过滤开发和生成环境不同的日志输出等级,来达到控制日志输出数量的目的,倒不如下苦功夫,找到并删除冗余日志。

我的观点是:这么详细的分级也许不是必须的,但在一个项目各个日志级别的定义应该是清楚明确的,需要团队的每个开发人员共同遵守。在现在的项目中,我使用到的主要是info记录代码执行流程和error记录错误日志。我的观点不一定正确,这可能是目前的经验还不够丰富造成的,关于日志的分级,需要从大量的实践中继续学习。

扩展阅读

2. 如何打日志

前面提到了日志的分类和级别,大致清楚应该在代码的什么地方编写对应等级的日志,接下来就需要了解应该如何编写日志了。这也是这篇文章的主要目的:在自己的代码中打正确的日志。

2.1. 编写合适的日志内容

日志编写的总体原则是简单清晰、便于排查问题,因为日志的主要内容是记录当前操作做了什么, 使用的什么数据. 好的日志应该被看成文档注释的一部分。

一般地,每一条日志数据会包括描述上下文两部分,此外,日志可能还需要记录对应的时间。一条日志应该易读, 清晰, 可描述. 要记录的当前操作做了什么, 使用的什么数据,好的日志应该被看成文档注释的一部分。


let a = 100
log(a + 200) // 这种没有描述的日志只会输出一个 300,翻日志的时候可能会一脸懵逼

if(date instanceof Date ){
    ...
}else {
    log('date类型不正确') // 这种没有上下文的日志很难定位到具体的输出点
}

// 日志应该具有很强的阅读性
log(`${new Date()}: downloadMonthReport ${year}${month}月的报表已下载完毕,准备开始下一条记录id:${id}的生成`)

输出日志时,要考虑日志的使用者,因为不同的使用者对于日志内容的需求可能是不一样的,开发可能希望快速定位代码位置,运维可能希望从日志直接获取系统资源状态,诸如此类。理想的日志内容中应该遵循下面规则

  • 不要在日志中记录无用的信息,否则就是浪费时间
  • 不要遗漏日志使用者所需的消息,否则该条日志就没有意义了

有说法提到打日志跟写小学作文一样:什么时候在什么地点发生了什么事。仔细想想,貌似确实有点道理~

2.2. 控制日志数量

生产环境里运行的程序如果没有日志会让维护者提心吊胆,有太多杂乱无章的日志也会让维护者心烦意燥。因此我们需要控制日志的数量,不要把日志写的像流水账一样。

不重要的信息尽量不要打印日志,通过配置日志的分级可以在一定程度上解决这个问题,不同等级的日志在数量级上是不一样的。

另外的一个问题是,我们不应该在逻辑代码中手动控制每一个日志的输出,除非某条日志的性能消耗十分严重;相反地,日志的输出决定权应该由配置项决定,一般这个工作会交给日志框架处理

// 最好不要出现下面代码
if(shouldLog) log('xxx');

// 而是应该直接明了的写log,至于是否输出日志应该在日志系统本身控制
log('xxx)

在前端开发中,如果日志需要上传,则更不应该频繁地记录和上传日志,因为如果网页的pv比较大,则上传的日志数量也会非常多,这会增加系统的负荷和分析时的复杂度,这个问题在后面的章节中会进一步讲解。

2.3. 正确处理异常日志

异常日志是日志分类中非常中要的一环,可以帮助我们快速准确定位并解决。打异常日志时,要记录关键参数,出错时的关键原因,这样可以快速复现和修复问题,下面是参考整理的关于异常日志的几条实践

  • 记录代码异常而不是用户异常(如未登录、用户名已存在),用户异常应该显示给用户,代码异常才是我们需要关注的
  • 日志中需要记录发生异常时的调用函数上下文和参数,尽量保留复现问题的现场
  • 记录异常的描述信息和调用堆栈,以及什么情况下回抛出该异常的描述
  • 异常日志应该拥有较高的日志级别

什么时候抛出异常也是一个比较讲究的事情,关于异常日志,可以参考

3. 前端开发中的日志

大部分成熟的后端框架都有非常完善的日志系统,而由于前端代码运行在浏览器中,这导致前端日志与服务端日志有一些根本上的差异,前端日志主要存在下面一些问题

  • JavaScript语言的动态性导致可能会产生很多被忽略的、未捕获的异常

  • Chrome开发调试实在是太方便了,以至于忽略了日志的重要性~(/掩面)

  • 由于默认的日志依赖控制台查看,在移动端无法直接看见输出的日志,虽然可以通过(eruda)(https://github.com/liriliri/eruda)或者[vConsole](https://github.com/Tencent/vConsole)解决这个问题

  • 如果不进行特殊处理,前端日志无法持久化,刷新或切换页面就会导致日志丢失,导致难以对线上问题进行追溯和分析;

尅前面两个问题可以归结到开发者身上,后面的问题其实可以看做同一个问题:前端日志上报

3.1. 日志上报

前端日志上报有下面几个理由

  • 为了监控前端应用是否正常运行,通常会在前端收集一些错误与性能等数据,但是开发者是无法直接访问到项目的运行环境的,需要通过将这些数据上报到服务端,然后才能进行分析
  • 日志上报解决了前端日志无法持久化的问题,可以通过上报将日志保存到文件、数据库等地方

一般地,日志上报都是通过发送网络请求,将日志记录上传到服务端,因为日志上报不需要响应处理,只需要把数据上传即可,常见的上报方式有

  • 0像素打点上传,通过构建img的src输出,在get请求拼接日志
  • xhr、fetch等网络请求,主动发起网络请求,主要用于提交内容较大的日志
  • script、link标签等可以发起网络请求的其他标签,与像素打点类似

然而日志上报会带来也会带来下面两个问题。

第一个问题:如果进行了日志上报,则每个访客都可能上报大量相同的日志,pv过大会导致日志存量极速增长,因此建议不要频繁地上传错误日志,这个问题可以通过增加日志采样率解决

function log(msg, sampling = 1){
    if(Math.random() < sampling){
        _log(msg)
    }
}

第二个问题:日志上报并不是应用的主要功能逻辑,不应该与其他重要的资源请求竞争网络;但是,日志又负责上传应用的错误与性能数据,因此也需要保证重要的日志上传到服务端。为了处理这个问题,可以使用信标Beacon

  • 使用Beacon表示我们通过浏览器发送了一个不需要响应的POST请求,该请求可以携带少量数据,而且信标请求优先避免与关键操作和更高优先级的网络请求竞争,这跟日志的使用场景十分契合

  • Beacon请求可以有效地合并,保证在移动设备上的性能。

  • Beacon请求的非阻塞性意味着性能比xhr和fetch都优秀,保证页面卸载之前启动信标请求,因此可以很方便地的统计页面unload时的信息,而不会阻塞页面的卸载

// resulttrue代表用户代理成功地将信标请求加入到队列中,否则返回false
let result = navigator.sendBeacon(url, data);

更多Beacon的使用可以参考

3.2. 前端异常处理

前端异常主要包括:JS语法错误和运行时发生的错误。利用JavaScript语言和DOM提供的捷径,可以在前端暴力式获取错误信息,如 try..catch 和 window.onerror。

try…catch

try {...}catch(e){
    log(e)
}

try..catch 应该只用来处理代码中无法控制的异常,如JSON.parsedecodeComponentURI等系统方法的调用,依赖不可控的参数类型。JS代码是自己编写的,应该想办法增加代码的鲁棒性,而不是一味地把自己的编码缺陷丢给catch块。

let sel = '#J_testBtn'
let btn = document.querySelector(sel)
if(btn && typeof btn.click === 'function'){
    btn.click()
}else {
    console.log(`未找到${sel}的按钮,无法执行click操作`)
}

window.onerror

window.onerror = function() {console.log(arguments)}

使用window.onerror需要注意下面几个问题

  • 检测全局的错误,使用该方法最好将其放在所有代码的前面,否则同步代码抛出的异常会导致该方法不被执行
  • 跨域之后window.onerror是无法捕获异常信息的,会统一返回Script error.,解决方案便是script属性配置 crossorigin=”anonymous” 并且在服务器配置CORS。

此外,一般前端框架也会提供一些处理异常的钩子,如

  • Vue中提供了errorHandler,可以用于指定组件的渲染和观察期间未捕获错误的处理函数。这个处理函数被调用时,可获取错误信息和 Vue 实例
  • 微信小程序提供了onError,当小程序发生脚本错误或 API 调用报错时触发。也可以使用 wx.onError 绑定监听。

目前还有有一些比较专业的应用错误监控平台,如fundebug等,提供错误监控和分析等服务。

4. 小结

这篇文章整理了关于在在代码中打日志的一些概念和思考,

  • 要重视日志,但日志并不是越多越好,要在合适的地方编写合适的日志内容
  • 日志需要分级,但是如何分级应该根据团队约定来定
  • 前端日志可以通过Beacon来上报,这个感觉可以尝试写一个日志库来实现下

日志最主要的目的是帮助我们快速定位问题,编写正确的日志并不是一件轻松的事情,在之后的项目中,希望自己能够重视这个问题。