12 自定义指标:如何通过自定义指标,监控前端页面更多状态
你好,我是三桥。
这节课,我们继续学习前端全链路的另一种链路日志,自定义日志。
通常来说,我们会在业务代码里主动输出一些日志,通过日志追踪可能发生的异常或流程以外的地方。
如果说,请求类日志、资源类日志、脚本异常类日志是全链路中最基础底层的日志,那么自定义日志就是更接近业务的链路日志。它的实现原理类似于封装console对象,也是为业务提供了一个链路日志的函数。
为什么要做全链路的自定义埋点
很多前端同学都喜欢在业务逻辑代码中使用 console.log
来打印日志信息,从而调试和定位问题,因为这种日志能够覆盖前端业务逻辑里很多特殊的场景。
这么做有什么问题吗?我们先来看一个很有代表性的例子。
try {
const jsonstr = '{"name": "前端全链路优化实战", "course": 30, "source": "geekbang"}'
var data = JSON.parse(jsonstr);
console.log(data.name);
console.log(data.course);
} catch (err) {
console.error(err);
}
上面的代码使用了 JSON.parse
函数,将一个json字符串解析为JSON对象。要知道,例子中展示的字符串是一个标准json字符串,对于 parse
函数而言,它是能正常解析出来的。
我们知道,任何一个函数的运行必然涉及输入和输出两个过程,而且数据的输入往往是不可靠的。 JSON.parse
函数同理,如果输入的字符串不符合JSON对象的格式要求,代码就会抛出一个 SyntaxError
异常。
其实,在真实的前端项目里,像 JSON.parse
这样的函数逻辑非常多。通常情况下,数据输入来自两个方面,一个是后端接口提供的数据,另一个就是来自用户输入,例如表单数据。如果我们不校验这样输入错误的逻辑,只要JavaScript代码报错,就没法往下执行,程序也会直接崩溃。这是我们不愿意看到的情况。
我们再回来分析上面的代码,虽然代码中使用了try-catch来捕获代码的异常情况,但我们并没有在catch中给出发生异常时的处理机制,只是偶尔会使用 console.log
输出error信息。
虽然进入catch事件的概率非常低,但只要是进入了异常,就肯定是我们没有预想到的情况。对于这些特殊情况,我们可以在全链路里面通过日志记录下来,帮助我们分析用户的真实情况,改善产品的用户体验。
总之, 要提前发现潜在的业务问题, 改善产品的用户体验,自定义链路日志是前端全链路必不可少的日志类型。
接下来,我们就来实现自定义链路日志的基本函数。
实现通用自定义链路日志
在前面的课程里,我们已经定义了全链路的基本数据结构。其中自定义链路日志的数据类型是 TraceDataLog
。这次,我们就以这种数据结构类型实现通用自定义日志函数。
通用底层日志函数
我们首先创建一个名为 log
的函数,参数为 TraceDataLog
数据类型。这么做的目的是根据业务的实际情况构造 TraceDataLog
数据,例如 level
、 type
、 message
、 name
等字段值。在 log
函数内部,我们只需关注存储用户行为和发送日志两项任务就好。
具体实现的代码参考如下。
// src/baseTrace.ts
export class BaseTrace implements BaseTraceInterface {
public log(log: TraceDataLog) {
this.saveBreadcrumb({
name: 'customer-log',
level: log.level,
type: dataTypes2BreadcrumbsType(log.type),
category: dataCategory2BreadcrumbsCategory(log.type),
message: log.message,
time: getTimestamp(),
})
this.send(log)
}
}
我们来看下 log
函数代码,在存储用户行为的数据中,type和category两个字段都需要通过额外的函数转换成合适的值,确保符合数据的一致性。
另外, log
函数的入参参数是一个完整的日志数据对象。这样的入参存在一个问题,就是调用log函数前,还要构造一个完整的日志数据对象,然后你会发现,对于业务来说,使用 log
函数这件事情变得更复杂了。
因此,我们以 log
函数为基础,实现3个预设不同级别的自定义日志函数,它们分别是普通日志info,告警日志warning,错误日志error。有了这3个级别的自定义日志,业务逻辑就能以最小代码量接入链路日志。
普通日志函数
普通日志函数的功能是记录业务逻辑中普通级别的日志信息,可以用来调试业务日志、自定义catch日志。
首先我们定义一个名为 info
的函数,它提供了两个入参参数:message和tag。 message
参数是用于记录日志信息的,而 tag
参数是为了区分不同的日志而提供的打标签的功能,当然这个参数是非必须的,可以自由选择。
具体实现的代码参考如下。
// src/baseTrace.ts
export class BaseTrace implements BaseTraceInterface {
public info(message: string, tag?: string) {
this.log({
name: 'customer-log',
type: TraceDataTypes.LOG,
level: TraceDataSeverity.Info,
message,
time: getTimestamp(),
dataId: hashCode(`${message}|${tag || ''}`),
tag,
})
}
}
告警日志函数
告警日志会记录业务逻辑里需要重点关注的地方。主要预警数据校验失败这类场景。如果校验失败,我们就可以考虑上报告警类型日志了。
warn
日志函数跟 info
函数的实现原理是相同的,唯一的区别是level属性使用的是 warning
值。
具体实现的代码参考如下。
// src/baseTrace.ts
export class BaseTrace implements BaseTraceInterface {
public warn(message: string, tag?: string) {
this.log({
name: 'customer-log',
type: TraceDataTypes.LOG,
level: TraceDataSeverity.Warning,
message,
time: getTimestamp(),
dataId: hashCode(`${message}|${tag || ''}`),
tag,
})
}
}
错误日志函数
错误日志函数的主要作用是捕获并记录业务逻辑中出现的代码异常。它能够详细记录异常信息,包括但不限于JSON解析错误,以及通过try-catch语句捕获的异常情况。通常来说,在代码中上报这种类型的日志,肯定是业务出现了一些问题。所以反过来看,每当出现线上问题的时候,自定义的错误日志必定是我们重点关注的地方。
错误日志函数与 info
和 warn
函数的实现基本上一样,唯一区别是level属性直接使用 error
值。具体实现的代码参考如下。
// src/baseTrace.ts
export class BaseTrace implements BaseTraceInterface {
public error(message: string, tag?: string) {
this.log({
name: 'customer-error',
type: TraceDataTypes.LOG,
level: TraceDataSeverity.Error,
message,
time: getTimestamp(),
dataId: hashCode(`${message}|${tag || ''}`),
tag,
})
}
}
dataId属性的特点
你可能已经注意到,这三个日志函数都使用了相同的算法生成 dataId
属性值,也就是 message
和 tag
两个参数值拼接组合,然后使用 hashCode
函数转换成新字符串。
这个算法有一个特点,就是会出现重复值。这样设计的原因是什么呢?
这是因为,在前端项目中,如果某一段业务逻辑里频繁出现同样的错误,例如100个用户中有10个都抛出了同样的异常,那就至少会出现10个相同dataId的日志。这说明这段代码逻辑存在问题,这时就需要投入时间排查原因了。
如何用好自定义链路埋点?
好了。学完了如何定义通用的自定义日志函数后,那怎么用呢?在什么场景下调用呢?
很多前端同学对自己实现的代码和逻辑都过于自信,很少考虑到出现逻辑异常时的流程和处理方案。最多就是为了调试问题而增加 console.log
日志。这时候你会发现,前端异常问题抛出来了,但日志确在用户手上,没法提供给前端同学,特别是使用 JSON.parse
转换格式的问题。
JSON转换格式问题
前面有一段示例代码,我是用try-catch捕获的 JSON.parse
函数解析异常。相信很多前端同学都会有意识地去增加捕获的日志输出,但在真实项目中,这部分还是会不知不觉地被忽略。
例如下面的代码。
你一定能看得懂上面的代码,而且大概率也写过类似的代码,直接在 push
函数的参数上调用 JSON.parse
函数,不做任何的try-catch容错处理,因为这样写真的很方便很简洁。这样的写法会导致什么问题?
答案是,当 JSON.parse
无法解析数据时,会直接抛出 SyntaxError
,导致 forEach
逻辑无法正常运行。
所以,使用JSON.parse函数时,我们不能因为方便就忽略容错处理。正确的做法是单独用一行代码去解析JSON数据,并在catch中增加上报错误日志。
看下示例代码。
// test.js
// 第二条字符串是不符合JSON格式的字符串
const data = [
'{"name": "前端全链路优化实战", "course": 30, "source": "geekbang"}',
'{"name": "前端全链路优化实战", "course": 30, "source"}',
'{"name": "前端全链路优化实战", "course": 30, "source": "geekbang"}',
]
const arr = []
data.forEach(item => {
try {
const itemObj = JSON.parse(item)
arr.push(itemObj)
} catch (err) {
console.error(err)
// 通过自定义链路日志函数上报到服务器
traceSDK.error(err.message, 'forEach|JSON.parse')
}
});
console.log(arr)
// SyntaxError: Expected ':' after property name in JSON at position 44 (line 1 column 45)
// at JSON.parse (<anonymous>)
// at auth:16:29
// at Array.forEach (<anonymous>)
// at test:14:8
//test:22 (2) [{…}, {…}]
取值的问题
除了 JSON.parse
这种JavaScript内置函数外,前端同学在做业务时还会封装大量函数来提高业务逻辑的复用。一个最典型的例子是前端同学使用 localStorage
特性做增删改的逻辑。
我们先来看下最常见的使用 localStorage
的例子。
function recommendCourseList() {
const result = localStorage.getItem('userInfo')
try {
const resObj = JSON.parse(result)
const list = getRecommendCourseList(resObj.userId);
return list
} catch (err) {
console.error(err)
return []
}
return []
}
在这段代码里,有三个关键代码逻辑,一个是使用 localstorage.getItem
读取 userInfo
数据,另一个是使用 JSON.parse
解析JSON对象 resObj
,最后是把 resObj
对象的 userId
传给 getRecommendCourseList
函数获取推荐课程列表。
在这里, JSON.parse
的问题和 getRecommendCourseList
的实现逻辑这两个地方就不再探讨。我们来重点看看 localStorage.getItem
带来了什么问题?
我们先理解下 localStorage.getItem
的作用, localStorage
是浏览器提供的一种在客户端存储数据的机制,它可以在客户端存储或读取数据,在一些简单的用户配置或本地缓存数据等场景都会用到。而getItem就是读取数据的方法。
既然数据是存储在浏览器本地,也就是说还得需要程序主动地存储才会有数据。那我们假设,在读取数据之前,就没有被存储起来,后续的使用数据逻辑应该怎么兼容呢?
我们先来分析下场景。
从上面的代码逻辑来看, getItem
读取数据存在不可预估的输出结果,是因为我们没有做容错处理吗?那后续的逻辑是我们预想要的结果吗?
换个角度,从业务逻辑来看,为什么getItem获取不到数据呢?是因为我们的逻辑不够严谨吗?还是说我们允许它存在空值呢?
在我看来,我们无法用任何数据或证据来证明我们猜测的问题。唯一能做的就是在真实的环境和用户行为下捕获问题的证据,才能知道如何解决。不管怎样,使用 localStorage.getItem
方法获取数据都是不可信任的,极有可能返回undefined。如果返回值是undefined,那运行JSON.parse就是不可预期的结果了。
现在,我们尝试来优化上面的代码,让代码出现异常时,依旧能捕获到现场的信息。
第一步就是把 localStorage.getItem
也一并放到try-catch里。
function recommendCourseList() {
try {
const result = localStorage.getItem('userInfo')
const resObj = JSON.parse(result)
const list = getRecommendCourseList(resObj.userId);
return list
} catch (err) {
console.error(err)
// 通过自定义链路日志函数上报到服务器
traceSDK.error(err.message, 'recommendCourseList')
return []
}
return []
}
好了,我们稍微移一下位置后,就暂时解决了前面的问题。不过, localStorage
逻辑和 JSON.parse
逻辑都放在同一个try-catch里,在真实的环境下,它们俩都有可能因为输入的不确定性导致异常报错。这样我们就很难判断究竟是哪一行代码报错的,是哪个位置的输入导致的问题。
所以,第二步优化是封装 localStorage.getItem
,让该函数能在更多场景下接入全链路的监视范围。
function getItem(key: string) {
try {
const result = localStorage.getItem(key)
if (result === undefined) {
console.warn(`GET KEY[${key}] Not Found.`)
return null
}
return result
} catch (err) {
console.warn(`GET KEY[${key}] error. message: ${err.message}`)
traceSDK.warn(`GET KEY[${key}] error. message: ${err.message}`)
return null
}
}
第三步优化,就是处理 recommendCourseList
函数内数据的容错处理,让我们能够实时了解真实异常情况。
function recommendCourseList() {
const result = getItem('userInfo')
if (result !== null) {
try {
const resObj = JSON.parse(result)
const list = getRecommendCourseList(resObj.userId);
return list
} catch (err) {
console.error(err)
// 通过自定义链路日志函数上报到服务器
traceSDK.error(err.message, 'recommendCourseList|JSON.parse')
return []
}
}
console.warn('get[userInfo] value is null. ')
traceSDK.warn('get[userInfo] value is null. ', 'recommendCourseList|getUserInfo')
return []
}
经过优化之后, recommendCourseList
函数的逻辑已经在我们的全链路监控范围内。
因为全链路日志都能覆盖到每个逻辑场景,例如输入输出的异常,有if就必须有else,内置函数的错误抛出等等。
Promise的reject函数
此外,还存在一种特殊的情况,就是异步函数的应用场景。通常,我们会用Promise对象来解决异步的问题。Promise对象是JavaScript中处理异步操作的一种机制,它可以帮助我们更好地处理异步逻辑。
我们都知道,在处理异步业务逻辑的过程中,Promise要面对许多复杂的情况。有些情况必须使用 reject
抛出失败、异常等状态,才能够让业务根据实际情况作出逻辑的调整。
既然这种异常、失败存在概率性问题,我们就需要监控这些概率问题,提早发现并修复。怎么做呢?最简单的方法就是增加链路日志,例如下面代码。
const example = new Promise(function(resolve, reject) {
try {
throw new Error('test');
} catch(e) {
reject(e);
}
})
example.catch(function(error) {
console.log(error);
traceSDK.warn(err.message, 'Promise|example|catch')
});
从上述代码可以看到,其实现在的逻辑只针对了当前的Promise对象,但并非每个业务Promise逻辑都会定义catch事件。那下一个问题就是,怎么才能捕获所有未定义catch事件的异步问题呢?
我们可以通过监听unhandlerrejection事件来达到捕获这些异步问题,具体实现的代码如下。
window.addEventListener('unhandledrejection', function(event) {
console.warn('Unhandled Promise Rejection:', event.reason);
traceSDK.warn(event.reason, 'Promise|example|catch')
});
更多业务场景
事实上,一个大型的前端应用,不止上面举例的三种代码逻辑场景。
首先是 核心业务流程。“核心”的定义是只要这段代码报错或者是函数运行报错都会导致JavaScript脚本执行中断,功能无法继续使用。这种情况就必须做好链路日志埋点工作,实时监控脚本运行的状态。
第二就是 使用第三方JavaScript库或SDK,例如前端和App原生的通讯SDK,小程序也会使用一些原生方法。依赖外部的交互往往是问题出现最频繁的地方,因此我们也必须要做好链路日志埋点,保证排查问题时能够通过日志判断问题的实际情况。
第三是前端页面有大量的 全局事件和元素事件,例如触屏事件 touchstart
和 touchend
、页面显示与隐藏的 pageHide
和 pageShow
事件,还有页面滚动事件 onScroll
等等。这些事件都会影响用户的使用体验,我建议根据实际情况去埋点链路日志,通过监控事件帮助提前发现问题。
总结
这节课我们重点学习了前端全链路的自定义链路日志,它能通过埋点监控页面或功能的状态,帮助前端同学在业务逻辑中提前发现问题。
在课程里,我们通过实现通用的日志函数和不同等级的日志函数两种方式,让前端同学可以在不同的业务场景下调用日志函数。同时,我还举了三种最常见的业务场景案例,帮助你了解自定义日志函数的使用方法。
前端全链路的自定义日志函数,是帮助业务逻辑发现问题而存在的。我们在实现业务逻辑时,不能总是关心功能是如何实现的,还必须关注功能的非正常流程状态以及异常情况的影响范围。
当然,有时候我们不可能一下子就把功能的实现想得很透,特别是经过多次迭代的同一份代码,更需要通过埋点来监控它,确保我们代码的质量能经过业务的考验。
需要再次强调的是,在全链路自定义日志埋点方案里,我们不能只关注错误埋点,非正常流程、异常捕获和核心逻辑也是全链路监控的重要组成部分。
思考题
现在留给你一道思考题。你现在负责的前端项目中,还有哪些业务场景需要通过链路日志重点监控呢?你应该如何优化这些业务场景和链路日志之间的逻辑关系呢?不妨尝试下。
欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!