Skip to content

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 数据,例如 leveltypemessagename 等字段值。在 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语句捕获的异常情况。通常来说,在代码中上报这种类型的日志,肯定是业务出现了一些问题。所以反过来看,每当出现线上问题的时候,自定义的错误日志必定是我们重点关注的地方。

错误日志函数与 infowarn 函数的实现基本上一样,唯一区别是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 属性值,也就是 messagetag 两个参数值拼接组合,然后使用 hashCode 函数转换成新字符串。

这个算法有一个特点,就是会出现重复值。这样设计的原因是什么呢?

这是因为,在前端项目中,如果某一段业务逻辑里频繁出现同样的错误,例如100个用户中有10个都抛出了同样的异常,那就至少会出现10个相同dataId的日志。这说明这段代码逻辑存在问题,这时就需要投入时间排查原因了。

如何用好自定义链路埋点?

好了。学完了如何定义通用的自定义日志函数后,那怎么用呢?在什么场景下调用呢?

很多前端同学对自己实现的代码和逻辑都过于自信,很少考虑到出现逻辑异常时的流程和处理方案。最多就是为了调试问题而增加 console.log 日志。这时候你会发现,前端异常问题抛出来了,但日志确在用户手上,没法提供给前端同学,特别是使用 JSON.parse 转换格式的问题。

JSON转换格式问题

前面有一段示例代码,我是用try-catch捕获的 JSON.parse 函数解析异常。相信很多前端同学都会有意识地去增加捕获的日志输出,但在真实项目中,这部分还是会不知不觉地被忽略。

例如下面的代码。

data.forEach((item: string) => {
    arr.push(JSON.parse(item));
});

你一定能看得懂上面的代码,而且大概率也写过类似的代码,直接在 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,小程序也会使用一些原生方法。依赖外部的交互往往是问题出现最频繁的地方,因此我们也必须要做好链路日志埋点,保证排查问题时能够通过日志判断问题的实际情况。

第三是前端页面有大量的 全局事件和元素事件,例如触屏事件 touchstarttouchend、页面显示与隐藏的 pageHidepageShow 事件,还有页面滚动事件 onScroll 等等。这些事件都会影响用户的使用体验,我建议根据实际情况去埋点链路日志,通过监控事件帮助提前发现问题。

总结

这节课我们重点学习了前端全链路的自定义链路日志,它能通过埋点监控页面或功能的状态,帮助前端同学在业务逻辑中提前发现问题。

在课程里,我们通过实现通用的日志函数和不同等级的日志函数两种方式,让前端同学可以在不同的业务场景下调用日志函数。同时,我还举了三种最常见的业务场景案例,帮助你了解自定义日志函数的使用方法。

前端全链路的自定义日志函数,是帮助业务逻辑发现问题而存在的。我们在实现业务逻辑时,不能总是关心功能是如何实现的,还必须关注功能的非正常流程状态以及异常情况的影响范围。

当然,有时候我们不可能一下子就把功能的实现想得很透,特别是经过多次迭代的同一份代码,更需要通过埋点来监控它,确保我们代码的质量能经过业务的考验。

需要再次强调的是,在全链路自定义日志埋点方案里,我们不能只关注错误埋点,非正常流程、异常捕获和核心逻辑也是全链路监控的重要组成部分。

思考题

现在留给你一道思考题。你现在负责的前端项目中,还有哪些业务场景需要通过链路日志重点监控呢?你应该如何优化这些业务场景和链路日志之间的逻辑关系呢?不妨尝试下。

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!