# 前端监控数据采集

# 错误数据采集

  • js代码运行错误、语法错误等
  • 异步错误
  • 静态资源加载错误
  • 接口请求错误

# 错误捕获方式

  1. try...catch只能捕获代码常规的运行错误,语法错误和异步错误不能捕获到
try {
  let a = undefined;
  if (a.length) {
    console.log('111');
  }
 } catch (e) {
  console.log('捕获到异常:', e);
}

// 语法错误
try {
  const notdefined,
} catch(e) {
  console.log('捕获不到异常:', 'Uncaught SyntaxError');
}

// 异步错误
try {
  setTimeout(() => {
    console.log(notdefined);
  }, 0)
} catch(e) {
  console.log('捕获不到异常:', 'Uncaught ReferenceError');
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  1. window.onerror可以捕获常规错误、异步错误,但不能捕获资源错误
window.onerror = function(message, source, lineno, colno, error) {
  console.log("捕获到的错误信息是:", message, source, lineno, colno, error);
};

// 示例1:常规运行时错误,可以捕获 ✅
console.log(notdefined);

// 示例2:语法错误,不能捕获 ❌
const notdefined;

// 示例3:异步错误,可以捕获 ✅
setTimeout(() => {
  console.log(notdefined);
}, 0);

// 示例4:资源错误,不能捕获 ❌
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "https://www.test.com/index.js";
document.body.appendChild(script);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  1. window.addEventListener:当静态资源加载失败时,会触发 error 事件, 此时 window.onerror 不能捕获到
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<script>
  window.addEventListener('error', (error) => {
    console.log('捕获到异常:', error);
  }, true)
</script>

<!-- 图片、script、css加载错误,都能被捕获 ✅ -->
<img src="https://test.cn/×××.png">
<script src="https://test.cn/×××.js"></script>
<link href="https://test.cn/×××.css" rel="stylesheet" />

<script>
  // new Image错误,不能捕获 ❌
  // new Image运用的比较少,可以自己单独处理
  new Image().src = 'https://test.cn/×××.png'
</script>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  1. Promise错误:Promise 中抛出的错误,无法被 window.onerrortry/catcherror 事件捕获到,可通过 unhandledrejection 事件来处理
try {
  new Promise((resolve, reject) => {
    JSON.parse('');
    resolve();
  });
} catch (err) {
  // try/catch 不能捕获Promise中错误 ❌
  console.error('in try catch', err);
}

// error事件 不能捕获Promise中错误 ❌
window.addEventListener(
  'error',
  (error) => {
    console.log('捕获到异常:', error);
  },
  true
);

// window.onerror 不能捕获Promise中错误 ❌
window.onerror = function (message, source, lineno, colno, error) {
  console.log('捕获到异常:', { message, source, lineno, colno, error });
};

// unhandledrejection 可以捕获Promise中的错误 ✅
window.addEventListener('unhandledrejection', function (e) {
  console.log('捕获到异常', e);
  // preventDefault阻止传播,不会在控制台打印
  e.preventDefault();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# Vue 错误

Vue 项目中,window.onerrorerror 事件不能捕获到常规的代码错误

// 异常错误
export default {
  created() {
    let a = null;
    if (a.length > 1) {
      // ...
    }
  }
};

// main.js添加错误 控制台会报错,但是 window.onerror 和 error 不能捕获到
window.addEventListener('error', (error) => {
  console.log('error', error);
});
window.onerror = function (msg, url, line, col, error) {
  console.log('onerror', msg, url, line, col, error);
};

// vue 通过  Vue.config.errorHander 来捕获异常
Vue.config.errorHandler = (err, vm, info) => {
  // handleError方法用来处理错误并上报
  handleError(err);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

vue 项目的处理类似,react 项目中,可以在 componentDidCatch 中将捕获的错误上报

# 跨域问题

如果当前页面中,引入了其他域名的 JS 资源,如果资源出现错误,error 事件只会监测到一个 script error 的异常。

window.addEventListener(
  'error',
  (error) => {
    console.log('捕获到异常:', error);
  },
  true
);

// 当前页面加载其他域的资源,如https://www.test.com/index.js
<script src="https://www.test.com/index.js"></script>;

// 加载的https://www.test.com/index.js的代码
function fn() {
  JSON.parse('');
}
fn();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

TIP

  • 原因: 是由于浏览器基于安全考虑,故意隐藏了其它域 JS 文件抛出的具体错误信息,这样可以有效避免敏感信息无意中被第三方(不受控制的)脚本捕获到,因此,浏览器只允许同域下的脚本捕获具体的错误信息。

  • 解决办法:

  1. 前端 scriptcrossorigin,后端配置 Access-Control-Allow-Origin
  2. 如果不能修改服务端的请求头,可以考虑通过使用 try/catch 绕过,将错误抛出
// 1
<script src="https://www.test.com/index.js" crossorigin></script>

// 2
<!doctype html>
<html>
<body>
  <script src="https://www.test.com/index.js"></script>
  <script>
  window.addEventListener("error", error => {
    console.log("捕获到异常:", error);
  }, true );

  try {
    // 调用https://www.test.com/index.js中定义的fn方法
    fn();
  } catch (e) {
    throw e;
  }
  </script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 接口错误

接口监控的实现原理:针对浏览器内置的 XMLHttpRequestfetch 对象,利用 AOP 切片编程重写该方法,实现对请求的接口拦截,从而获取接口报错的情况并上报

// 拦截 XMLHttpRequest 请求示例:
function xhrReplace() {
  if (!('XMLHttpRequest' in window)) {
    return;
  }
  const originalXhrProto = XMLHttpRequest.prototype;
  // 重写XMLHttpRequest 原型上的open方法
  replaceAop(originalXhrProto, 'open', (originalOpen) => {
    return function (...args) {
      // 获取请求的信息
      this._xhr = {
        method: typeof args[0] === 'string' ? args[0].toUpperCase() : args[0],
        url: args[1],
        startTime: new Date().getTime(),
        type: 'xhr'
      };
      // 执行原始的open方法
      originalOpen.apply(this, args);
    };
  });
  // 重写XMLHttpRequest 原型上的send方法
  replaceAop(originalXhrProto, 'send', (originalSend) => {
    return function (...args) {
      // 当请求结束时触发,无论请求成功还是失败都会触发
      this.addEventListener('loadend', () => {
        const { responseType, response, status } = this;
        const endTime = new Date().getTime();
        this._xhr.reqData = args[0];
        this._xhr.status = status;
        if (['', 'json', 'text'].indexOf(responseType) !== -1) {
          this._xhr.responseText =
            typeof response === 'object' ? JSON.stringify(response) : response;
        }
        // 获取接口的请求时长
        this._xhr.elapsedTime = endTime - this._xhr.startTime;

        // 上报xhr接口数据
        reportData(this._xhr);
      });
      // 执行原始的send方法
      originalSend.apply(this, args);
    };
  });
}

/**
 * 重写指定的方法
 * @param { object } source 重写的对象
 * @param { string } name 重写的属性
 * @param { function } fn 拦截的函数
 */
function replaceAop(source, name, fn) {
  if (source === undefined) return;
  if (name in source) {
    var original = source[name];
    var wrapped = fn(original);
    if (typeof wrapped === 'function') {
      source[name] = wrapped;
    }
  }
}

// 拦截 fetch 请求示例:
function fetchReplace() {
  if (!('fetch' in window)) {
    return;
  }
  // 重写fetch方法
  replaceAop(window, 'fetch', (originalFetch) => {
    return function (url, config) {
      const sTime = new Date().getTime();
      const method = (config && config.method) || 'GET';
      let handlerData = {
        type: 'fetch',
        method,
        reqData: config && config.body,
        url
      };

      return originalFetch.apply(window, [url, config]).then(
        (res) => {
          // res.clone克隆,防止被标记已消费
          const tempRes = res.clone();
          const eTime = new Date().getTime();
          handlerData = {
            ...handlerData,
            elapsedTime: eTime - sTime,
            status: tempRes.status
          };
          tempRes.text().then((data) => {
            handlerData.responseText = data;
            // 上报fetch接口数据
            reportData(handlerData);
          });

          // 返回原始的结果,外部继续使用then接收
          return res;
        },
        (err) => {
          const eTime = new Date().getTime();
          handlerData = {
            ...handlerData,
            elapsedTime: eTime - sTime,
            status: 0
          };
          // 上报fetch接口数据
          reportData(handlerData);
          throw err;
        }
      );
    };
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113

# 性能数据采集

谈到性能数据采集,就会提及加载过程模型图:

img

Spa 页面来说,页面的加载过程大致是这样的:

img

包括 dns 查询、建立 tcp 连接、发送 http 请求、返回 html 文档、html 文档解析等阶段

最初,可以通过 window.performance.timing 来获取加载过程模型中各个阶段的耗时数据

// window.performance.timing 各字段说明
{
  navigationStart, // 同一个浏览器上下文中,上一个文档结束时的时间戳。如果没有上一个文档,这个值会和 fetchStart 相同。
  unloadEventStart, // 上一个文档 unload 事件触发时的时间戳。如果没有上一个文档,为 0。
  unloadEventEnd, // 上一个文档 unload 事件结束时的时间戳。如果没有上一个文档,为 0。
  redirectStart, // 表示第一个 http 重定向开始时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
  redirectEnd, // 表示最后一个 http 重定向结束时的时间戳。如果没有重定向或者有一个非同源的重定向,为 0。
  fetchStart, // 表示浏览器准备好使用 http 请求来获取文档的时间戳。这个时间点会在检查任何缓存之前。
  domainLookupStart, // 域名查询开始的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
  domainLookupEnd, // 域名查询结束的时间戳。如果使用了持久连接或者本地有缓存,这个值会和 fetchStart 相同。
  connectStart, // http 请求向服务器发送连接请求时的时间戳。如果使用了持久连接,这个值会和 fetchStart 相同。
  connectEnd, // 浏览器和服务器之前建立连接的时间戳,所有握手和认证过程全部结束。如果使用了持久连接,这个值会和 fetchStart 相同。
  secureConnectionStart, // 浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,返回 0。
  requestStart, // 浏览器向服务器发起 http 请求(或者读取本地缓存)时的时间戳,即获取 html 文档。
  responseStart, // 浏览器从服务器接收到第一个字节时的时间戳。
  responseEnd, // 浏览器从服务器接受到最后一个字节时的时间戳。
  domLoading, // dom 结构开始解析的时间戳,document.readyState 的值为 loading。
  domInteractive, // dom 结构解析结束,开始加载内嵌资源的时间戳,document.readyState 的状态为 interactive。
  domContentLoadedEventStart, // DOMContentLoaded 事件触发时的时间戳,所有需要执行的脚本执行完毕。
  domContentLoadedEventEnd, // DOMContentLoaded 事件结束时的时间戳
  domComplete, // dom 文档完成解析的时间戳, document.readyState 的值为 complete。
  loadEventStart, // load 事件触发的时间。
  loadEventEnd; // load 时间结束时的时间。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

后来 window.performance.timing 被废弃,通过 PerformanceObserver 来获取。旧的 api,返回的是一个 UNIX 类型的绝对时间,和用户的系统时间相关,分析的时候需要再次计算。而新的 api,返回的是一个相对时间,可以直接用来分析。

# 性能指标计算web-vitals (opens new window)

关于 FP、FCP、LCP、CLS、TTFB、FID 等性能指标的含义和计算方式

性能指标: FP(First Paint):首次绘制时间,包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻。 FCP(First Content Paint):首次内容绘制。是浏览器将第一个 DOM 渲染到屏幕的时间,可能是文本、图像、SVG 等。这其实就是白屏时间 FMP(First Meaningful Paint):首次有意义绘制。页面有意义的内容渲染的时间 LCP(Largest Contentful Paint)。最大内容渲染。代表在 viewport 中最大的页面元素加载的时间。 DCL(DomContentLoaded)DOM 加载完成。当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发。无需等待样式表,图像和子框架的完成加载。 L(onload):当依赖的资源全部加载完毕之后才会触发。 TTI(Time to Interactive):可交互时间。用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点。 FID(First Input Delay):首次输入延迟。用户首次和页面交互(单击链接、点击按钮等)到页面响应交互的时间。

用户体验: TTFB(Time To First Byte 首字节时间)。是指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间。 FP(First Paint 首次绘制)。首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时间。 FCP(First Content Paint 首次内容绘制)。首次内容绘制是浏览器将第一个 DOM 渲染到屏幕的时间,可以是任何文本、图像、SVG 等的时间。 FMP(First Meaningful Paint 首次有意义绘制)。 首次有意义绘制是页面可用性的量度标准。 FID(First Input Delay 首次输入延迟)。用户首次和页面交互到页面响应交互的时间。

业务: PVpage view 即页面浏览量或点击量 UV:指访问某个站点的不同 IP 地址的人数。

# 用户行为数据采集

用户行为包括:页面路由变化、鼠标点击、资源加载、接口调用、代码报错等行为

# 设计思路

1、通过 Breadcrumb 类来创建用户行为的对象,来存储和管理所有的用户行为 2、通过重写或添加相应的事件,完成用户行为数据的采集

// 创建用户行为类
class Breadcrumb {
  // maxBreadcrumbs控制上报用户行为的最大条数
  maxBreadcrumbs = 20;
  // stack 存储用户行为
  stack = [];
  constructor() {}
  // 添加用户行为栈
  push(data) {
    if (this.stack.length >= this.maxBreadcrumbs) {
      // 超出则删除第一条
      this.stack.shift();
    }
    this.stack.push(data);
    // 按照时间排序
    this.stack.sort((a, b) => a.time - b.time);
  }
}

let breadcrumb = new Breadcrumb();

// 添加一条页面跳转的行为,从home页面跳转到about页面
breadcrumb.push({
  type: "Route",
  form: '/home',
  to: '/about'
  url: "http://localhost:3000/index.html",
  time: "1668759320435"
});

// 添加一条用户点击行为
breadcrumb.push({
  type: "Click",
  dom: "<button id='btn'>按钮</button>",
  time: "1668759620485"
});

// 添加一条调用接口行为
breadcrumb.push({
  type: "Xhr",
  url: "http://10.105.10.12/monitor/open/pushData",
  time: "1668760485550"
});

// 上报用户行为
reportData({
  uuid: "a6481683-6d2e-4bd8-bba1-64819d8cce8c",
  stack: breadcrumb.getStack()
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 页面跳转

通过监听路由的变化来判断页面跳转,路由有historyhash两种模式,history 模式可以监听popstate事件,hash 模式通过重写 pushStatereplaceState事件。vue 项目中不能通过 hashchange 事件来监听路由变化,vue-router 底层调用的是 history.pushStatehistory.replaceState,不会触发 hashchange

//通过重写 pushState、replaceState 事件来监听路由变化

// lastHref 前一个页面的路由
let lastHref = document.location.href;
function historyReplace() {
  function historyReplaceFn(originalHistoryFn) {
    return function (...args) {
      const url = args.length > 2 ? args[2] : undefined;
      if (url) {
        const from = lastHref;
        const to = String(url);
        lastHref = to;
        // 上报路由变化
        reportData('routeChange', {
          from,
          to
        });
      }
      return originalHistoryFn.apply(this, args);
    };
  }
  // 重写pushState事件
  replaceAop(window.history, 'pushState', historyReplaceFn);
  // 重写replaceState事件
  replaceAop(window.history, 'replaceState', historyReplaceFn);
}

function replaceAop(source, name, fn) {
  if (source === undefined) return;
  if (name in source) {
    var original = source[name];
    var wrapped = fn(original);
    if (typeof wrapped === 'function') {
      source[name] = wrapped;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

# 用户点击

document 对象添加 click 事件,并上报

function domReplace() {
  document.addEventListener(
    'click',
    ({ target }) => {
      const tagName = target.tagName.toLowerCase();
      if (tagName === 'body') {
        return null;
      }
      let classNames = target.classList.value;
      classNames = classNames !== '' ? ` class="${classNames}"` : '';
      const id = target.id ? ` id="${target.id}"` : '';
      const innerText = target.innerText;
      // 获取包含id、class、innerTextde字符串的标签
      let dom = `<${tagName}${id}${classNames !== '' ? classNames : ''}>${innerText}</${tagName}>`;
      // 上报
      reportData({
        type: 'Click',
        dom
      });
    },
    true
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23