前端监控

599 阅读11分钟

1.为什么要做前端监控

  1. 更快的发现和解决线上问题
  2. 做产品的决策依据
  3. 提升前端工程师的技术深度和广度,打造项目亮点
  4. 为业务扩展提供了更多的可能性

2.前端监控的目标

2.1 收集项目稳定性数据(stability)

错误名称备注
JS错误JS执行错误或者promise异常
资源异常script、link 等资源加载异常
接口错误ajax或fetch请求接口异常
白屏页面空白

2.2 收集用户体验指标数据(stability)

加载时间各个阶段的加载时间
TTFB(time to first byte)(首字节时间)是指浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间
FP(First Paint)(首次绘制)首次绘制包括了任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻
FCP(First Content Paint)(首次内容绘制)首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间
FMP(First Meaningful paint)(首次有意义绘制)FMP(First Meaningful paint)(首次有意义绘制)
FID(First Input Delay)(首次输入延迟)用户首次和页面交互到页面响应交互的时间
卡顿超过50ms的长任务

2.2 收集业务数据(business)

指标名称备注
PVpage view 即页面浏览量或点击量
UV单元指访问某个站点的不同IP地址的人数格
页面的停留时间用户在每一个页面的停留时间

3. 前端监控流程

  1. 前端埋点
  2. 数据上报
  3. 分析和计算 将采集到的数据进行加工汇总
  4. 可视化展示 将数据按各种维度进行展示
  5. 监控报警 发现问题后按一定的条件触发报警 emm,手抖图有点歪。 数据建模指的是采集到数据后,在前端按照约定的格式去组装数据并存储。 日志上报服务器是分为实时和批量两种方式。

对前端开发人员的来说,最主要关注埋点时机和数据采集即可,针对后面的流程,业界有比较成熟的方案了,比如阿里云有个 SLS 日志分析系统。

4. 常见的埋点方案

4.1 代码埋点

  • 代码埋点,就是以嵌入代码的形式进行埋点,比如需要监控用户的点击事件,会选择在用户点击时,插入一段代码,保存这个监听行为或者直接将监听行为以某一种数据格式直接传递给服务器端
  • 优点是可以在任意时刻,精确的发送或保存所需要的数据信息
  • 缺点是工作量较大

4.2 可视化埋点

  • 通过可视化交互的手段,代替代码埋点
  • 将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后输出的代码耦合了业务代码和埋点代码
  • 可视化埋点其实是用系统来代替手工插入埋点代码
  • 缺点也是不够灵活

4.3 无痕(全量)埋点

  • 前端的任意一个事件都被绑定一个标识,所有的事件都记录下来
  • 通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告供专业人员分析
  • 无痕埋点的优点是采集全量数据,不会出现漏埋和误埋等现象
  • 缺点是给数据传输和服务器增加压力,也无法灵活定制数据结构

5. 创建项目,编写监控采集脚本

mkdir xyy-monitor-sdk

cd xyy-monitor-sdk

cnpm init -y

cnpm i webpack@^4.46.0 webpack-cli^4.10.0 html-webpack-plugin@^4.5.2 webpack-dev-server@^4.11.1 @webpack-cli/serve^1.7.0 user-agent@^1.0.4 -D

目录结构如下

配置 webpack,打包我们的 html 和 js 代码

webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    context: process.cwd(),
    mode: 'development', // 不会压缩,有利于分析打包代码
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'monitor.js',
    },
    devServer: {
        static: path.join(__dirname, 'dist'), // 静态文件目录
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html',
            inject: 'head', // 注入 monitor.js 的位置,这里 js 要注入到上方 head 标签内部,先于 body 后注入的 js 执行
        }),
    ]
}


package.json 添加服务启动命令。

{
  "name": "xyy-monitor-sdk",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.js",
  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@webpack-cli/serve": "^1.7.0",
    "html-webpack-plugin": "^4.5.2",
    "user-agent": "^1.0.4",
    "webpack": "^4.46.0",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.11.1"
  }
}

其余文件留空即可。

npm run dev 启动服务

浏览器打开 http://localhost:8080/

6. 手把手接入阿里云日志服务 SLS

日志服务(Log Service, 简称 SLS)是针对日志类数据一站式服务,用户无需开发就能快捷完成数据采集、消费、投递以及查询分析等功能,帮助提升运维、运营效率,建立 DT(数据处理技术) 时代海量日志处理能力,钉钉有2亿用户,使用的也是这个日志服务。

首先我们登录阿里云,开通 阿里云日志应用

选择创建项目:

创建项目后,会提示创建 store:

然后提示是否立即接入数据,选择是:

弹出的窗口中搜索 webTracking - SDK 写入:

点击下一步:

选择开启中文,点击下一步,创建完成。

选择立刻尝试:

这就到了我们的查询页面了,可以看到此时我们是没有数据的,借助 浏览器 JavaScript SDK 接入文档,我们来实现一个 utils/tracker.js 去上报我们的错误日志。

let host = 'cn-beijing.log.aliyuncs.com'; // 日志服务的域名
let project = 'ysmonitor'; // 项目名
let logstore  = 'ysmonitor-store'; // 日志库名
import userAgent from 'user-agent'; // 浏览器信息
import SlsTracker from '@aliyun-sls/web-track-browser'; // sls 日志服务上报

const opts = {
    host: `${host}`, // 所在地域的服务入口。例如cn-hangzhou.log.aliyuncs.com
    project: `${project}`, // Project名称。
    logstore: `${logstore}`, // Logstore名称。
    time: 10, // 发送日志的时间间隔,默认是10秒。
    count: 10, // 发送日志的数量大小,默认是10条。
    topic: 'topic', // 自定义日志主题。
    source: 'xyy-lz', // 自定义日志来源。
    tags: { // 日志标签信息。您可以自定义该字段,便于识别。
      tags: 'tags',
    },
}


class SendTracker {
    constructor() {
        this.tracker = new SlsTracker(opts);
    }

    send(data)  {
        let extraData = { // 额外的公共数据
            timestamp: Date.now(),
            userAgent: userAgent.parse(navigator.userAgent).name, // 将 userAgent 转成一个对象
            userId: localStorage.getItem('userId') || '',
        };
        
        let log = { ...extraData, ...data };

        // 阿里云要求对象的值必须是字符串
        for (let key in log) {
            if (typeof log[key] !== 'string') {
                log[key] = JSON.stringify(log[key]);
            }
        }

        // 发送日志提供了4种方式  send 延时单条  sendImmediate 立即单条  sendBatchLogs 延时批量  sendBatchLogsImmediate 立即批量
        this.tracker.sendImmediate(log); // 发送日志
    }
}

export default new SendTracker();

该方法接收日志对象,并实现上报阿里云 store。

此时刷新我们的 store,就能看到我上报的数据了,其中包含格式不正确几条测试数据(这里表格太丑了,我们可以切换到原始哈)。

点击右上角的查询分析属性 -> 属性。

修改完成后,左侧就会显示所有的索引信息。 接下来的实现中,我们会重度使用该服务,这里暂且不表。

7. 采集项目稳定性相关数据

采集 JS错误(js 执行执行错误和 promise 异常)

关键代码如下

Js 错误日志预期格式

let log = {
    "kind": "stability", // 监控指标的大类 这里代表稳定性相关的监控指标
    "type": "error", // 小类 这是一个错误
    "errorType": "jsError", // 错误的类型
    "message": event.message, // 报错信息
    "filename": event.filename, // 访问的文件名
    "position": `19:36`,// 行列信息
    "stack": "at xxx", // 堆栈信息
    "selector": "xx" // 代表最后一个操作的元素 -> "html body div#container div.content input"
}

需要注意的是,如果通过 try catch 捕获错误后,就不会再触发以下方法。

捕捉 JsError

 window.addEventListener("error", function (event) {
    console.log('catched js runned error');
 }, true); // 事件捕获阶段触发 这里的 true 必不可少

捕捉 promiseError

 window.addEventListener("unhandledrejection", function (event) {
    console.log('catched promise error');
 }, true);
 // 需要注意,当promise内部发生未捕获的语法错误时,reason 是个对象 { reason: xxx, stack: xxx },根据 stack 能解析出行列和文件等信息。
new Promise((resolve, reject) => {
    reject('自定义reject字段'); // event.reason = "promise error"
});
 // 直接 reject 却未捕获时,reason 是个 reject 出的字符串 "自定义reject字段"
 new Promise((resolve, reject) => {
    window.someVar.error = 'error'; // 未捕获的 promise 内部语法错误  event.reason = { reason: xxx, stack: xxx }
});

ok,上真实代码,主要涉及到一些格式化。

1. src/index.js 入口文件引入监控初始化代码

import './monitor';

2. 新建 monitor/index.js,引入 JsError 捕获的初始化代码

import { injectJsError } from "./lib/jsError";

injectJsError();

3. 新建 monitor/lib/jsError.js,捕捉 jsError 的两种错误

import getLastEvent from '../utils/getLastEvent';
import getSeletor from '../utils/getSelector';
import tracker from '../utils/tracker';

export function injectJsError() {
    // 监听全局未捕获的异常
    window.addEventListener('error', function (event) {
        console.log('on js error', event);
        let lastEvent = getLastEvent(); // 获取最后一个操作的事件对象

        tracker.send({
            'kind': 'stability', // 监控指标的大类 这里代表稳定性相关的监控指标
            'type': 'error', // 小类型 这是一个错误
            'errorType': 'jsError',// js 执行错误
            'message': event.message, // 报错信息
            'filename': event.filename, // 访问的文件名
            'position': `${ event.lineno }:${ event.colno }`,// 行列信息
            'stack': getLines(event.error.stack), // 堆栈信息
            'selector': lastEvent ? getSeletor(lastEvent.path) : '' // 代表最后一个操作的元素 -> 'html body div#container div.content input'
        }); 
    }, true);

    window.addEventListener('unhandledrejection', function (event) {
        console.log('unhandledrejection', event)
        const lastEvent =  getLastEvent();
        let message;
        let reason = event.reason;
        let filename;
        let line;
        let column;
        let stack = '';

        if (typeof reason === 'string') {
            message = reason;
        } else if (typeof reason === 'object') {
            // message = reason.message;
            if (reason.stack) {
                // 从 ... at http://localhost:8080/:25:38\n ... 中拿出文件名,行列信息
                let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);

                filename = matchResult[1];
                line = matchResult[2];
                column = matchResult[3];
                stack = getLines(reason.stack);
            }

            message = reason.message;
        }

        tracker.send({
            'kind': 'stability', // 监控指标的大类 这里代表稳定性相关的监控指标
            'type': 'error', // 小类型 这是一个错误
            'errorType': 'promiseError', // promise 错误
            message, // 报错信息
            filename, // 访问的文件名
            position: `${ line }:${ column }`,// 行列信息
            'stack': stack, // 堆栈信息
            'selector': lastEvent ? getSeletor(lastEvent.path) : '' // 代表最后一个操作的元素 -> 'html body div#container div.content input'
        });
    }, true);

    // 格式化错误堆栈信息
    function getLines(stack) {
        // 'TypeError: Cannot set properties of undefined (setting 'error')
        // at errorClick (http://localhost:8080/:19:34)
        // at HTMLInputElement.onclick (http://localhost:8080/:12:78)'
        // 变成
        //  'errorClick (http://localhost:8080/:19:34)^HTMLInputElement.onclick (http://localhost:8080/:12:78)'
        return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g, '').replace(/^(\S+\s+){4}/, '')).join('^');
    }
}

4. 新建 monitor/utils/getLastEvent.js.js,获取报错前最后一个执行的事件,很取巧的方式哦

let lastEvent; // 记录最后一个操作的事件的事件对线

['click', 'touchstart', 'mousedown', 'keydown', 'mouseover'].forEach(function (eventType) {
    document.addEventListener(eventType, function (event) {
        lastEvent = event;
    }, {
        capture: true, // 捕获阶段执行
        passive: true // 不阻止默认行为
    });
});

export default function () {
    return lastEvent;
}

5. 新建 monitor/utils/getSelector.js,获取报错前操作的真实元素信息

const getSelector = function (path) {
    return path.reverse().filter(function (element) {
        return element !== window && element !== document;
    }).map(function (element) {
        var selector;
        if (element.id) {
            selector = `#${element.id}`;
        } else if (element.className && typeof element.className === 'string') {
            selector = '.' + element.className.split(' ').filter(function (item) { return !!item }).join('.');
        } else {
            selector = element.nodeName;
        }
        return selector;
    }).join(' ');
}
export default function (path) {
    if (Array.isArray(path)) { // 获取 js 报错操作的最后一个按钮数据
        return getSelector(path);
    }
}

6. 修改 src/index.html,手动触发 jsError 用于测试

<body>
+    <div id="container">
+        <div class="content">
+            <input id="errorBtn" type="button" value="点击抛出 jsError" onClick="errorClick()">
+            <input id="promiseErrorBtn" type="button" value="点击抛出 promiseError" onClick="promiseErrorClick()">
+        </div>
+    </div>
+
+    <script>
+        function errorClick() {
+            window.someVar.error = 'error'; // 访问未定义的变量 制造 error
+        }
+
+        function promiseErrorClick() {
+            new Promise((resolve, reject) => {
+                // reject('自定义reject字段'); // event.reason = "promise error"
+                window.someVar.error = 'error'; // 未捕获的 promise 内部语法错误  event.reason = { reason: xxx, stack: xxx }
+            });
+        }
+    </script>
</body>

页面截图

js 执行错误相关落库字段 promise 错误相关落库字段

采集资源加载异常错误

关键代码如下

资源加载错误日志预期格式

let log = {
    'kind': 'stability', // 监控指标的大类 这里代表稳定性相关的监控指标
    'type': 'error', // 小类型 这是一个错误
    'errorType': 'resourceError', // 资源加载错误
    'filename': event.target.src || event.target.href, // 访问的文件名,这里指资源路径 // localhost:8000/someError.js
    'tagName': event.target.tagName, // 标签名 script || link
    'selector': '' // 对应的加载元素,html body script || html script link
}

注意,资源错误走的是也是全局的 error 方法,也就是捕获 js 运行错误的方法,区别在于 event.target 记录有当前的加载标签信息。

捕捉资源加载错误

 window.addEventListener("error", function (event) {
    console.log('catched js runned error');
 }, true); // 事件捕获阶段触发 这里的 true 必不可少

上代码,主要也是一些格式化代码。

修改 src/index.html,加入 srcipt 标签,引入不存在的文件。

<body>
    ...

+    <script src="someError.js"></script>
</body>

修改 src/monitor/lib/jsError.js,在 error 事件中,区分捕捉资源加载错误并上报

    // 监听全局未捕获的异常
    window.addEventListener('error', function (event) {
        console.log('on js error', event);
        let lastEvent = getLastEvent(); // 获取最后一个操作的事件对象

+        if (event.target && (event.target.src || event.target.href)) { // 脚本加载错误
+            console.log('脚本加载错误拉');
+            tracker.send({
+                'kind': 'stability', // 监控指标的大类 这里代表稳定性相关的监控指标
+                'type': 'error', // 小类型 这是一个错误
+                'errorType': 'resourceError', // 资源加载错误
+                'filename': event.target.src || event.target.href, // 访问的文件名,这里指资源路径
+                'tagName': event.target.tagName, // 标签名
+               'selector': getSeletor(event.target) // 传入 event.target 去获取到对应的加载元素 > html body script
+            }); 
+        } else {  // js 运行报错
+            tracker.send({
+                'kind': 'stability', // 监控指标的大类 这里代表稳定性相关的监控指标
+                'type': 'error', // 小类型 这是一个错误
+                'errorType': 'jsError',// js 执行错误
+                'message': event.message, // 报错信息
+                'filename': event.filename, // 访问的文件名
+                'position': `${ event.lineno }:${ event.colno }`,// 行列信息
+                'stack': getLines(event.error.stack), // 堆栈信息
+                'selector': lastEvent ? getSeletor(lastEvent.path) : '' // 代表最后一个操作的元素 -> 'html body div#container div.content input'
+            }); 
+        }
+    }, true);

修改 src/monitor/utils/getSelector.js,获取加载资源的元素路径


export default function (pathsOrTarget) {
    if (Array.isArray(pathsOrTarget)) { // 获取 js 报错操作的最后一个按钮数据
        return getSelector(pathsOrTarget);
+    } else { // 针对资源加载错误的情况 获取错误资源的 script 或者 link 标签路径
+        var paths = [];
+        var element = pathsOrTarget;
+        while (element) {
+            paths.push(element);
+            element = element.parentNode;
+        }
+        return getSelector(paths);
+    }
}

刷新页面,看到加载错误。

落库字段:

接口异常采集

关键代码如下

接口异常日志预期格式

let log = {
  "title": "前端监控系统", //标题
  "url": "http://localhost:8080/", //url
  "timestamp": "1590817024490", //timestamp
  "userAgent": "Chrome", //浏览器版本
  "kind": "stability", //大类
  "type": "xhr", //小类
  "eventType": "load", //事件类型,在什么事件中捕获到的接口事件
  "pathname": "/success", //路径
  "status": "200-OK", //状态码
  "duration": "7", //持续时间
  "response": "{\"id\":1}", //响应内容
  "params": ""  //参数
}

捕捉请求错误

let xhr = window.XMLHttpRequest;

xhr.addEventListener('load', handler('load'), false); // 请求完成走这里,200 400 500 等
xhr.addEventListener('error', handler('error'), false); // 请求未成功
xhr.addEventListener('abort', handler('abort'), false); // 请求被取消

修改 src/index.html,增加测试按钮

+   <div id="container">
+        <div class="content">
+            <input type="button" value="发起ajax成功请求" onclick="sendAjaxSuccess()" />
+            <input type="button" value="发起ajax失败请求" onclick="sendAjaxError()" />
+        </div>
+    </div>
+
+    <script>
+        function sendAjaxSuccess() { // 成功态会捕获 200 400 500 等请求,代表请求到接口到返回成功。
+           let xhr = new XMLHttpRequest;
+           xhr.open('GET', '/success', true);
+           xhr.responseType = 'json';
+           xhr.onload = function () {
+               console.log(xhr.response);
+           }
+           xhr.send();
+       }
+       function sendAjaxError() { // 请求一个不存在的api地址 会被onerror捕获
+           let xhr = new XMLHttpRequest;
+           xhr.open('POST', 'https://somewhere.org/i-dont-exist', true);
+           xhr.responseType = 'json';
+           xhr.onload = function () {
+               console.log(xhr.response);
+           }
+           xhr.onerror = function (error) {
+               console.log(error);
+           }
+           xhr.send("name=yangshuai");
+       }
+    </script>

修改 webpack.config.js,增加测试接口

    devServer: {
        static: path.join(__dirname, 'dist'), // 静态文件目录
+        setupMiddlewares(middlewares, devServer) {
+            devServer.app.get('/success', function (req, res) {
+                res.json({ id: 1 });
+            });
+
+            return middlewares;
+        }
    },

新增关键文件 src\monitor\lib\xhr.js,实现 xhr 拦截上报

  • 因为 axios 在浏览器环境使用的是 XMLHttpRequest,node 端使用 http(这里不考虑 node 端),所以我们需要在 xhr 中统一进行上报。
  • 重写,给 xhr 添加默认行为,也就是 before 方法。
// 采集 ajax 异常数据
import tracker from '../utils/tracker';
export function injectXHR() {
    let XMLHttpRequest = window.XMLHttpRequest;
    let oldOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url, async) {
        if (!url.match(/logstores/) && !url.match(/sockjs/)) { // 排除日志上报和 webpack hot-reload
            this.logData = { // 请求发送前,记录请求信息
                method, url, async
            }
        }
        return oldOpen.apply(this, arguments);
    }
    let oldSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.send = function (body) {
        if (this.logData) { // 请求发送后,上报接口信息
            let start = Date.now();
            let handler = (type) => (event) => {
                let duration = Date.now() - start; 
                let status = this.status;
                let statusText = this.statusText;

                tracker.send({//未捕获的promise错误
                    kind: 'stability',//稳定性指标
                    type: 'xhr',//xhr
                    eventType: type,// load error abort
                    pathname: this.logData.url,//接口的url地址
                    status: status + "-" + statusText,
                    duration: "" + duration, //接口耗时
                    response: this.response ? JSON.stringify(this.response) : "",
                    params: body || ''
                })
            }
            this.addEventListener('load', handler('load'), false);
            this.addEventListener('error', handler('error'), false);
            this.addEventListener('abort', handler('abort'), false);
        }
        oldSend.apply(this, arguments);
    };
}

修改入口文件,初始化 xhr 收集

import { injectJsError } from './lib/jsError';
+import { injectXHR } from './lib/xhr';

injectJsError();
+injectXHR();

页面上报如下: 落库字段,请求路径:

采集白屏数据

白屏就是页面上什么都没有,怎么去衡量什么都没有这个概念呢,这里我用到了 elementsFromPoint,此 API 可以获取到当前视口内指定坐标处,由里到外排列的所有元素。

总体思路就是,在页面全部元素加载完毕后,我从页面取18个点的元素,如果空白元素大于14,我认为页面白屏了。

if (document.readyState === 'complete') {
    calcEmptyElm();
} else {
    window.addEventListener('load', calcEmptyElm());
}

白屏日志数据预期格式

let log = {
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590822618759",
  "userAgent": "chrome",
  "kind": "stability",      //大类
  "type": "blank",          //小类
  "emptyPoints": "0",       //空白点数量 我获取了18组[x,y]坐标处的元素,如果14处都没有元素,我认为白屏(这里根据各自业务来调整)
  "screen": "2049x1152",    //分辨率
  "viewPoint": "2048x994",  //视口宽高
  "selector": "HTML BODY #container" //选择器 这里我使用的是屏幕正中心的点
}

修改 src/index.html

<body>
    <div id="container">
+        <div class="content" style="width:100%; word-wrap: break-word;">
-
+        </div>
    </div>

    <script>
+        let content = document.getElementsByClassName('content')[0];
+        content.innerHTML = '@'.repeat(10000); // 页面插入元素 进行测试
    </script>
</body>

修改 monitor\index.js,引入白屏收集工具方法进行初始化

import { injectJsError } from "./lib/jsError"; // js执行错误和promise错误监控
import { injectXHR } from './lib/xhr'; // 请求异常监控
import { blankScreen } from './lib/blankScreen'; // 白屏监控

injectJsError();
injectXHR();
blankScreen();

创建 src\monitor\lib\blankScreen.js,实现取点计数,白屏判断和上报功能。

// screen.width  屏幕的宽度   screen.height 屏幕的高度
// window.innerWidth 去除工具条与滚动条的窗口宽度 window.innerHeight 去除工具条与滚动条的窗口高度
import tracker from '../utils/tracker';
import onload from '../utils/onLoad';

function getSelector(element) {
    var selector;

    if (element.id) {
        selector = `#${element.id}`;
    } else if (element.className && typeof element.className === 'string') {
        selector = '.' + element.className.split(' ').filter(function (item) { return !!item }).join('.');
    } else {
        selector = element.nodeName.toLowerCase();
    }
    return selector;
}
export function blankScreen() {
    const wrapperSelectors = ['body', 'html', '#container', '.content']; // 最外层元素的包裹元素,我们认为它是空元素且不让其参与计数
    let emptyPoints = 0;

    function isWrapper(element) { // 是否为空元素
        let selector = getSelector(element);
        if (wrapperSelectors.indexOf(selector) >= 0) {
            emptyPoints++;
        }
    }

    onload(function () {
        let xElements, yElements;

        for (let i = 1; i <= 9; i++) {
            xElements = document.elementsFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
            yElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight * i / 10)

            isWrapper(xElements[0]); // 0 代表最内层的元素
            isWrapper(yElements[0]);
        }
        
        console.log(emptyPoints, '空元素个数')
        if (emptyPoints >= 14) { // 空元素大于14 上报
            let centerElements = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2); // 中心最内层元素

            tracker.send({
                kind: 'stability',
                type: 'blank',
                emptyPoints: "" + emptyPoints,
                screen: window.screen.width + "x" + window.screen.height,
                viewPoint: window.innerWidth + 'x' + window.innerHeight,
                selector: getSelector(centerElements[0]),
            })
        }
    });
}

创建 src\monitor\util\onload.js 方法,保证打点计数是在页面全部内容加载完毕后执行

export default function (callback) {
    if (document.readyState === 'complete') {
        callback();
    } else {
        window.addEventListener('load', callback);
    }
};

页面效果:

清空页面再次测试:


    <div id="container">
        <div class="content" style="width:100%; word-wrap:break-word;">

        </div>
    </div>

    <script>
-       let content = document.getElementsByClassName('content')[0];
-       content.innerHTML = '<span>@</span>'.repeat(10000);
    </script>

页面效果: 落库效果:

8. 采集用户体验指标相关数据

这里涉及浏览器渲染的过程,performanceAPI 包含各阶段指标。

浏览器渲染各阶段对应事件

字段含义

PerformanceTiming 字段含义:

字段含义
navigationStart初始化页面,在同一个浏览器上下文中前一个页面unload的时间戳,如果没有前一个页面的unload,则与fetchStart值相等
redirectStart第一个HTTP重定向发生的时间,有跳转且是同域的重定向,否则为0
redirectEnd最后一个重定向完成时的时间,否则为0
fetchStart浏览器准备好使用http请求获取文档的时间,这发生在检查缓存之前
domainLookupStartDNS域名开始查询的时间,如果有本地的缓存或keep-alive则时间为0
domainLookupEndDNS域名结束查询的时间
connectStartTCP开始建立连接的时间,如果是持久连接,则与fetchStart值相等
secureConnectionStarthttps 连接开始的时间,如果不是安全连接则为0
connectEndTCP完成握手的时间,如果是持久连接则与fetchStart值相等
requestStartHTTP请求读取真实文档开始的时间,包括从本地缓存读取
requestEndHTTP请求读取真实文档结束的时间,包括从本地缓存读取
responseStart返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的Unix毫秒时间戳
responseEnd返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时的Unix毫秒时间戳
unloadEventStart前一个页面的unload的时间戳 如果没有则为0
unloadEventEnd与unloadEventStart相对应,返回的是unload函数执行完成的时间戳
domLoading返回当前网页DOM结构开始解析时的时间戳,此时document.readyState变成loading,并将抛出readyStateChange事件
domInteractive返回当前网页DOM结构结束解析、开始加载内嵌资源时时间戳,document.readyState 变成interactive,并将抛出readyStateChange事件(注意只是DOM树解析完成,这时候并没有开始加载网页内的资源)
domContentLoadedEventStart网页domContentLoaded事件发生的时间
domContentLoadedEventEnd网页domContentLoaded事件脚本执行完毕的时间,domReady的时间
domCompleteDOM树解析完成,且资源也准备就绪的时间,document.readyState变成complete.并将抛出readystatechange事件
loadEventStartload 事件发送给文档,也即load回调函数开始执行的时间
loadEventEndload回调函数执行完成的时间

阶段计算

字段描述计算方式意义
unload前一个页面卸载耗时unloadEventEnd – unloadEventStart-
redirect重定向耗时redirectEnd – redirectStart重定向的时间
appCache缓存耗时domainLookupStart – fetchStart读取缓存的时间
dnsDNS 解析耗时domainLookupEnd – domainLookupStart可观察域名解析服务是否正常
tcpTCP 连接耗时connectEnd – connectStart建立连接的耗时
sslSSL 安全连接耗时connectEnd – secureConnectionStart反映数据安全连接建立耗时
ttfbTime to FirstByte(TTFB)网络请求耗时responseStart – requestStartTTFB是发出页面请求到接收到应答数据第一个字节所花费的毫秒数
response响应数据传输耗时responseEnd – responseStart观察网络是否正常
domDOM解析耗时domInteractive – responseEnd观察DOM结构是否合理,是否有JS阻塞页面解析
dclDOMContentLoaded 事件耗时domContentLoadedEventEnd – domContentLoadedEventStart当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载
resources资源加载耗时domComplete – domContentLoadedEventEnd可观察文档流是否过大
domReadyDOM阶段渲染耗时domContentLoadedEventEnd – fetchStartDOM树和页面资源加载完成时间,会触发domContentLoaded事件
首次渲染耗时首次渲染耗时responseEnd-fetchStart加载文档到看到第一帧非空图像的时间,也叫白屏时间
首次可交互时间首次可交互时间domInteractive-fetchStartDOM树解析完成时间,此时document.readyState为interactive
首包时间耗时首包时间responseStart-domainLookupStartDNS解析到响应返回给浏览器第一个字节的时间
页面完全加载时间页面完全加载时间loadEventStart - fetchStart-

定义上报格式

let log = {
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828364183",
  "userAgent": "chrome",
  "kind": "experience", // 用户体验相关
  "type": "timing", // 渲染时间
  "connectTime": "0",
  "ttfbTime": "1",
  "responseTime": "1",
  "parseDOMTime": "80",
  "domContentLoadedTime": "0",
  "timeToInteractive": "88",
  "loadTime": "89"
}

上报各阶段加载时间相关日志

修改 src\index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>monitor</title>
</head>

<body>
    <div id="container">
        <div class="content" style="width:600px;word-wrap:break-word;">

        </div>
    </div>

    <script>
        let content = document.getElementsByClassName('content')[0];
        //content.innerHTML = '@'.repeat(10000);
+        document.addEventListener('DOMContentLoaded', function () {
+            let start = Date.now();
+            while ((Date.now() - start) < 1000) {} // 阻塞一秒
+        });
    </script>
</body>

</html>

引入 src\monitor\index.js,进行用户体验上报的初始化

import { injectJsError } from './lib/jsError';
import { injectXHR } from './lib/xhr';
import { blankScreen } from './lib/blankScreen';
+import { timing } from './lib/timing';

injectJsError();
injectXHR();
blankScreen();
+timing();

创建 src\monitor\lib\timing.js,实现渲染各阶段耗时上报

import onload from '../utils/onload';
import tracker from '../utils/tracker';
import formatTime from '../utils/formatTime';
import getLastEvent from '../utils/getLastEvent';
import getSelector from '../utils/getSelector';

export function timing() {
    onload(function () {
        setTimeout(() => {
            const {
                fetchStart,
                connectStart,
                connectEnd,
                requestStart,
                responseStart,
                responseEnd,
                domLoading,
                domInteractive,
                domContentLoadedEventStart,
                domContentLoadedEventEnd,
                loadEventStart } = performance.timing;

            tracker.send({
                kind: 'experience',
                type: 'timing',
                connectTime: connectEnd - connectStart,//TCP连接耗时
                ttfbTime: responseStart - requestStart,//ttfb首字节加载
                responseTime: responseEnd - responseStart,//Response响应耗时
                parseDOMTime: loadEventStart - domLoading,//DOM解析渲染耗时
                domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,//DOMContentLoaded事件回调耗时
                timeToInteractive: domInteractive - fetchStart,//首次可交互时间
                loadTime: loadEventStart - fetchStart//完整的加载时间
            });

        }, 3000);
    });
}

入库字段:

上报关键性能指标(注意收集方式)

上报格式

// 绘制相关上报
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828364186",
  "userAgent": "chrome",
  "kind": "experience", // 用户性能指标
  "type": "paint", // 绘制性能
  "firstPaint": "102", // 首次绘制 FP 包括背景的绘制 第一个px绘制
  "firstContentPaint": "2130", // 首次内容区绘制 FCP 首次内容绘制是浏览器将第一个DOM渲染到屏幕的时间
  "firstMeaningfulPaint": "2130", // FMP 第一次用户声明的有意义的绘制(标签字段 element)
  "largestContentfulPaint": "2130" // LCP 第一个用户用户声明的大元素绘制(标签字段 largest-contentful-paint)
}
// 首次可交互上报
{
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590828477284",
  "userAgent": "chrome",
  "kind": "experience",
  "type": "firstInputDelay",
  "inputDelay": "3", // 从交互到事件开始处理
  "duration": "8", // 交互持续时间 
  "startTime": "4812.344999983907", // 交互时间
  "selector": "HTML BODY #container .content H1" // 操作元素
}

修改 index.html,声明有意义的标签

+        setTimeout(() => {
+            let h1 = document.createElement('h1');
+            h1.innerHTML = '我是最有重要的内容';
+            h1.setAttribute('elementtiming', 'meaningful');
+            content.appendChild(h1);
+        }, 2000);

修改 timing.js

import onload from '../util/onload';
import tracker from '../util/tracker';
import formatTime from '../util/formatTime';
import getLastEvent from '../util/getLastEvent';
import getSelector from '../util/getSelector';
export function timing() {
+    let FMP, LCP;
+    new PerformanceObserver((entryList, observer) => { // 性能监听
+        let perfEntries = entryList.getEntries(); // 绘制的性能指标数组
+        FMP = perfEntries[0];
+        observer.disconnect(); // 干掉监听
+    }).observe({ entryTypes: ['element'] }); // 监听类型, element 代表有意义的绘制

+    new PerformanceObserver((entryList, observer) => {
+        const perfEntries = entryList.getEntries();
+        const lastEntry = perfEntries[perfEntries.length - 1];
+        LCP = lastEntry;
+        observer.disconnect();
+    }).observe({ entryTypes: ['largest-contentful-paint'] });

+    new PerformanceObserver(function (entryList, observer) { // 首次用户点击触发这里
+        let lastEvent = getLastEvent(); // 最后操作的日志
+        const firstInput = entryList.getEntries()[0];
+        if (firstInput) {
+            let inputDelay = firstInput.processingStart - firstInput.startTime;//处理延迟
+            let duration = firstInput.duration;//处理耗时
+            if (firstInput > 0 || duration > 0) {
+                tracker.send({
+                    kind: 'experience',
+                    type: 'firstInputDelay',
+                    inputDelay: inputDelay ? formatTime(inputDelay) : 0,
+                    duration: duration ? formatTime(duration) : 0,
+                    startTime: firstInput.startTime,
+                    selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : ''
+                });
+            }
+        }
+        observer.disconnect();
+    }).observe({ type: 'first-input', buffered: true });


    onload(function () {
        setTimeout(() => {
            const {
                fetchStart,
                connectStart,
                connectEnd,
                requestStart,
                responseStart,
                responseEnd,
                domLoading,
                domInteractive,
                domContentLoadedEventStart,
                domContentLoadedEventEnd,
                loadEventStart } = performance.timing;
            tracker.send({
                kind: 'experience',
                type: 'timing',
                connectTime: connectEnd - connectStart,//TCP连接耗时
                ttfbTime: responseStart - requestStart,//ttfb
                responseTime: responseEnd - responseStart,//Response响应耗时
                parseDOMTime: loadEventStart - domLoading,//DOM解析渲染耗时
                domContentLoadedTime: domContentLoadedEventEnd - domContentLoadedEventStart,//DOMContentLoaded事件回调耗时
                timeToInteractive: domInteractive - fetchStart,//首次可交互时间
                loadTime: loadEventStart - fetchStart//完整的加载时间
            });
+            const FP = performance.getEntriesByName('first-paint')[0];
+            const FCP = performance.getEntriesByName('first-contentful-paint')[0];
+            console.log('FP', FP);
+            console.log('FCP', FCP);
+            console.log('FMP', FMP);
+            console.log('LCP', LCP);
+            tracker.send({
+                kind: 'experience',
+                type: 'paint',
+                firstPaint: FP ? formatTime(FP.startTime) : 0,
+                firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
+                firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
+                largestContentfulPaint: LCP ? formatTime(LCP.renderTime || LCP.loadTime) : 0
+            });
        }, 3000);
    });
}

刷新页面,绘制相关上报如下: 点击页面内的 "加油,前端监控SDK",查看上报接口: 入库记录:

收集 longTask(长耗时任务,卡顿)

Long Tasks API 用于观察浏览器主线程的阻塞时间和时长到足以影响帧率或输入延迟(卡顿),我们使用它去报告执行时间超过 100 毫秒 (ms) 的任何任务。

修改 index.html

    <div id="container">
        <div class="content" style="width:100%; word-wrap:break-word;">
+            <button id="longTaskBtn">执行longTask</button>
        </div>
    </div>

    <script>
+    let longTaskBtn = document.getElementById('longTaskBtn');
+    longTaskBtn.addEventListener('click', longTask);

+    function longTask() { // 耗时200ms的长任务
+       let start = Date.now();
+        console.log('longTask开始 start', start);
+        while (Date.now() < (200 + start)) { }
+        console.log('longTask结束 end', (Date.now() - start));
+    }
    </script>

创建 longTask.js,监控 longtask 任务

import tracker from '../utils/tracker';
import formatTime from '../utils/formatTime';
import getLastEvent from '../utils/getLastEvent';
import getSelector from '../utils/getSelector';

export function longTask() {
    new PerformanceObserver((list) => {
        list.getEntries().forEach(entry => {
            if (entry.duration > 100) {
                let lastEvent = getLastEvent();

                requestIdleCallback(() => {
                    tracker.send({
                        kind: 'experience',
                        type: 'longTask',
                        eventType: lastEvent.type,
                        startTime: formatTime(entry.startTime),// 开始时间
                        duration: formatTime(entry.duration),// 持续时间
                        selector: lastEvent ? getSelector(lastEvent.path || lastEvent.target) : ''
                    });
                });
            }
        });
    }).observe({ entryTypes: ["longtask"] });
}

修改 src/montor/index.js,初始化 longTask.js

+import { longTask } from './lib/longTask'; // 长耗时任务

+longTask();

9. 采集业务相关日志(pv 和 uv, 页面停留时间, 访问深度等)

pv 的收集通常是在页面卸载载(unload)的时候进行的。

数据结构

let log = {
  "title": "前端监控系统",
  "url": "http://localhost:8080/",
  "timestamp": "1590829304423",
  "userAgent": "chrome",
  "kind": "business",
  "type": "pv",
  "effectiveType": "4g",
  "screen": "2049x1152"
}

新建 src/monitor/lib/pv.js,实现 vue 统计 pv 和 页面停留时间,因为用到了 router, 提供 vue 项目 Vue.use(window.montorInstall(router)) 初始化的方式。

import tracker from '../utils/tracker';

export function pv() {
    let startTime;

    // 针对 vue 阻止默认事件,实现路由切换上报 pv
    window.montorInstall = (router) => {
        return {
            install(_Vue) {
                let startTime = null; // 浏览开始时间 首次为null

                router.beforeEach((to, from, next) => {
                    if (to.path) {
                        const connection = navigator.connection;
                        
                        console.log(to, 'to.path');
                        if (startTime) { // 上报停留时间
                            let stayTime = Date.now() - startTime;

                            tracker.send({
                                kind: 'business',
                                type: 'stayTime',
                                stayTime,
                                stayPage: from.fullPath // 停留的页面
                            });
                        }

                        tracker.send({
                            kind: 'business',
                            type: 'pv',
                            effectiveType: connection.effectiveType, //网络环境
                            pathname: to.path,
                            rtt: connection.rtt,//往返时间
                            screen: `${window.screen.width}x${window.screen.height}`//设备分辨率
                        });
                    }

                    startTime = Date.now();
                    next();
                });
            }
        }
    }
}

如果是普通多页可以这样写:

    window.addEventListener('load', function () {
        startTime = Date.now();
        const connection = navigator.connection;

        tracker.send({
            kind: 'business',
            type: 'pv',
            pathname: location.pathname,
            effectiveType: connection.effectiveType, //网络环境
            rtt: connection.rtt,//往返时间
            screen: `${window.screen.width}x${window.screen.height}`//设备分辨率
        });
    })


    window.addEventListener('unload', () => {
        let stayTime = Date.now() - startTime;

        tracker.send({
            kind: 'business',
            type: 'stayTime',
            stayTime
        });
    }, false);

修改 src/monitor/index.js,初始化 pv 上报

import { injectJsError } from "./lib/jsError"; // js执行错误和promise错误监控
import { injectXHR } from './lib/xhr'; // 请求异常监控
import { blankScreen } from './lib/blankScreen'; // 白屏监控
import { timing } from './lib/timing'; // 性能监控(ttfb, fp, fcp, fmp, lcp)
import { longTask } from './lib/longTask'; // 长耗时任务
+import { pv } from './lib/pv'; // 页面访问pv

injectJsError();
injectXHR();
blankScreen();
timing();
longTask();
+pv();

pv 入库截图: 停留时间入库截图:

访问深度

这里可以自行扩展,如果只有一屏页面(页面高度 < window.innerWidth),则访问深度为页面高度,否则为卷曲高度 + window.innerHeight

10. 使用 Navigator.sendBeacon(path, data) 上报数据

这里我用到了一个新的api,Navigator.sendBeacon(),正如它描述的那样,它主要用于将统计数据发送到 Web 服务器,同时避免了用传统技术(如:XMLHttpRequest)发送分析数据的一些问题,使用 sendBeacon() 方法会使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。

  1. 针对埋点的上报,我们一般在页面 unload 时执行,避免过早上报,错失用户后续操作数据。
  2. 一些手段会阻止页面卸载,不如现在常规的 1x1 gif 去上报,或者 xml 请求 或者一个暴力的死循环去阻止页面关闭,争取时间,不过用户体验太差
  3. 跳转到新页面再发送请求,通过 url 传参或者 location.name(设置了之后,即使切换页面也不会变)或者缓存存储重发,这些开发体验都不太好

所以有了 Navigator.sendBeacon 方法,它本身就是一个 xhr post 方法,我们需要把前面的 track 改成 post 的方式进行上传优化。

修改 utils/tracker.js

..

class SendTracker {
    constructor() {
-        this.tracker = new SlsTracker(opts);
    }

    send(data)  {
        let extraData = { // 额外的公共数据
            timestamp: Date.now(),
            userAgent: userAgent.parse(navigator.userAgent).name, // 将 userAgent 转成一个对象
            userId: localStorage.getItem('userId') || '',
        };
        
        let log = { ...extraData, ...data };

        // 阿里云要求对象的值必须是字符串
        for (let key in log) {
            if (typeof log[key] !== 'string') {
                log[key] = JSON.stringify(log[key]);
            }
        }

        // 发送日志提供了4种方式  send 延时单条  sendImmediate 立即单条  sendBatchLogs 延时批量  sendBatchLogsImmediate 立即批量
+       this.sendBeacon(log); // 发送日志
    }

+    sendBeacon(body) {
+        let path = `https://${project}.${host}/logstores/${logstore}/track?APIVersion=0.6.0`;
+        let headers = {
+            Host: `${project}.${host}`
+        }
+
+        body = {
+            "__logs__": [body],
+            "__tags__": {"tags": "tags"},
+            "__topic__": "topic",
+            "__source__": "xyy-lz"
+        }
+
+        const blob = new Blob([JSON.stringify(body)], headers);
+        navigator.sendBeacon(path, blob);
+    }
}

export default new SendTracker();

这样一个基础的监控 SDK 就完成了,可以根据业务自行扩展要收集的指标,

结语

SLS 日志库也提供了图表和 sql 查询/导出 功能,可以自行体验,有的公司使用 hive 数仓,也可以的。

这里提供几个常用的 sql 命令:

// 监控项根据类型分布
* | SELECT type, COUNT(*) as number GROUP BY type LIMIT 10

// 浏览器分布
* | SELECT userAgent, COUNT(*) as number GROUP BY userAgent LIMIT 10

// 页面分辨率分布
* | SELECT screen, COUNT(*) as number GROUP BY screen LIMIT 10